Skip to content
2,010 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.

Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01//! 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,
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)27 /// Chat: the conversations a person is in (channels, direct messages,
28 /// threads), read and answered as them on the site's chat routes and
29 /// live sockets. A person's own; no preset gives it.
30 Chat,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.031 Workspace,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit32 Billing,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.033 Repo,
34 Code,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar35 Security,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member36 Packages,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.037 Issues,
38 PullRequests,
39 Agents,
40 Workflows,
Token reach: workflow_files scope, fine-grained reach, workspace token cap41 WorkflowFiles,
Merge checks: statuses and check runs on every commit42 Checks,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9743 Deployments,
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.44 /// Observability: what a project's running software did, as requests,
45 /// logs, traces, errors and metrics, and how they are collected.
46 Observability,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.047 Memory,
48 Access,
49 Webhooks,
50 Secrets,
51 Runners,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens52 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 yet53 /// Artifacts mode's docs, slides, designs and dashboards (folios in
Merge main into Artifacts Phase 254 /// code): the `artifact` MCP tool and `/workspaces/{ws}/artifacts`.
55 /// Not workflow runs' artifacts, which are `workflows:*`.
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 yet56 Artifacts,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.057}
58
59impl Resource {
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.60 pub const ALL: [Resource; 24] = [
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.061 Resource::Repo,
62 Resource::Code,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar63 Resource::Security,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member64 Resource::Packages,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.065 Resource::Issues,
66 Resource::PullRequests,
67 Resource::Agents,
68 Resource::Workflows,
Token reach: workflow_files scope, fine-grained reach, workspace token cap69 Resource::WorkflowFiles,
Merge checks: statuses and check runs on every commit70 Resource::Checks,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9771 Resource::Deployments,
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.72 Resource::Observability,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.073 Resource::Memory,
74 Resource::Account,
API: notifications over REST and MCP, with notifications scopes75 Resource::Notifications,
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)76 Resource::Chat,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.077 Resource::Workspace,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit78 Resource::Billing,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.079 Resource::Access,
80 Resource::Webhooks,
81 Resource::Secrets,
82 Resource::Runners,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens83 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 yet84 Resource::Artifacts,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.085 ];
86
87 pub fn as_str(self) -> &'static str {
88 match self {
89 Resource::Account => "account",
API: notifications over REST and MCP, with notifications scopes90 Resource::Notifications => "notifications",
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)91 Resource::Chat => "chat",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.092 Resource::Workspace => "workspace",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit93 Resource::Billing => "billing",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.094 Resource::Repo => "repo",
95 Resource::Code => "code",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar96 Resource::Security => "security",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member97 Resource::Packages => "packages",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.098 Resource::Issues => "issues",
99 Resource::PullRequests => "pull_requests",
100 Resource::Agents => "agents",
101 Resource::Workflows => "workflows",
Token reach: workflow_files scope, fine-grained reach, workspace token cap102 Resource::WorkflowFiles => "workflow_files",
Merge checks: statuses and check runs on every commit103 Resource::Checks => "checks",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97104 Resource::Deployments => "deployments",
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.105 Resource::Observability => "observability",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0106 Resource::Memory => "memory",
107 Resource::Access => "access",
108 Resource::Webhooks => "webhooks",
109 Resource::Secrets => "secrets",
110 Resource::Runners => "runners",
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens111 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 yet112 Resource::Artifacts => "artifacts",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0113 }
114 }
115
116 /// Its name, for people.
117 pub fn label(self) -> &'static str {
118 match self {
119 Resource::Account => "Your account",
API: notifications over REST and MCP, with notifications scopes120 Resource::Notifications => "Notifications",
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)121 Resource::Chat => "Chat",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0122 Resource::Workspace => "Workspaces",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit123 Resource::Billing => "Billing",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0124 Resource::Repo => "Repositories",
125 Resource::Code => "Code",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar126 Resource::Security => "Security",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member127 Resource::Packages => "Packages",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0128 Resource::Issues => "Issues",
129 Resource::PullRequests => "Pull requests",
130 Resource::Agents => "g1t agents",
131 Resource::Workflows => "Workflows",
Token reach: workflow_files scope, fine-grained reach, workspace token cap132 Resource::WorkflowFiles => "Workflow files",
Merge checks: statuses and check runs on every commit133 Resource::Checks => "Checks and statuses",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97134 Resource::Deployments => "Deployments",
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.135 Resource::Observability => "Observability",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0136 Resource::Memory => "Memory and context",
137 Resource::Access => "Who has access",
138 Resource::Webhooks => "Webhooks",
139 Resource::Secrets => "Secrets and variables",
140 Resource::Runners => "Self-hosted runners",
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens141 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 yet142 Resource::Artifacts => "Artifacts",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0143 }
144 }
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 yet145
Merge main into Artifacts Phase 2146 /// Whether tokens are offered it yet. A resource being built can be in
147 /// the table before its API ships (so its scopes parse, and the
148 /// TypeScript mirror lists it under `UPCOMING_RESOURCES`) while nothing
149 /// hands it out: presets, full access, OAuth and the token form leave
150 /// it out, and no operation needs it. Every resource is offered now;
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.151 /// Artifacts was the last.
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 yet152 pub fn offered(self) -> bool {
Merge main into Artifacts Phase 2153 let _ = self;
154 true
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 yet155 }
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0156}
157
158/// How much of a resource.
159#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
160pub enum Level {
161 Read,
162 Write,
163 /// Starting g1t's agents, which spends the workspace's money.
164 Run,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member165 /// Deleting what cannot be brought back, such as a package's versions.
166 Delete,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0167 Admin,
168}
169
170impl Level {
171 pub fn as_str(self) -> &'static str {
172 match self {
173 Level::Read => "read",
174 Level::Write => "write",
175 Level::Run => "run",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member176 Level::Delete => "delete",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0177 Level::Admin => "admin",
178 }
179 }
180}
181
182/// One scope. Its text form, `resource:level`, is what tokens store, OAuth
183/// clients ask for, and errors name.
184#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
185pub enum Scope {
186 RepoRead,
187 RepoWrite,
188 RepoAdmin,
189 CodeRead,
190 CodeWrite,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar191 SecurityRead,
192 SecurityWrite,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member193 PackagesRead,
194 PackagesWrite,
195 PackagesDelete,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0196 IssuesRead,
197 IssuesWrite,
198 PullRequestsRead,
199 PullRequestsWrite,
200 AgentsRun,
201 WorkflowsRead,
202 WorkflowsWrite,
Token reach: workflow_files scope, fine-grained reach, workspace token cap203 WorkflowFilesWrite,
Merge checks: statuses and check runs on every commit204 ChecksRead,
205 ChecksWrite,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97206 DeploymentsRead,
207 DeploymentsWrite,
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.208 ObservabilityRead,
209 ObservabilityWrite,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0210 MemoryRead,
211 MemoryWrite,
212 AccountRead,
213 AccountWrite,
API: notifications over REST and MCP, with notifications scopes214 NotificationsRead,
215 NotificationsWrite,
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)216 ChatRead,
217 ChatWrite,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0218 WorkspaceRead,
219 WorkspaceAdmin,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit220 BillingRead,
221 BillingWrite,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0222 AccessRead,
223 AccessAdmin,
224 WebhooksRead,
225 WebhooksAdmin,
226 SecretsRead,
227 SecretsAdmin,
228 RunnersRead,
229 RunnersAdmin,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens230 ModelsRead,
231 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 yet232 ArtifactsRead,
233 ArtifactsWrite,
234 ArtifactsAdmin,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0235}
236
237impl Scope {
238 /// Every scope, grouped by resource, least first.
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.239 pub const ALL: [Scope; 49] = [
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0240 Scope::RepoRead,
241 Scope::RepoWrite,
242 Scope::RepoAdmin,
243 Scope::CodeRead,
244 Scope::CodeWrite,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar245 Scope::SecurityRead,
246 Scope::SecurityWrite,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member247 Scope::PackagesRead,
248 Scope::PackagesWrite,
249 Scope::PackagesDelete,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0250 Scope::IssuesRead,
251 Scope::IssuesWrite,
252 Scope::PullRequestsRead,
253 Scope::PullRequestsWrite,
254 Scope::AgentsRun,
255 Scope::WorkflowsRead,
256 Scope::WorkflowsWrite,
Token reach: workflow_files scope, fine-grained reach, workspace token cap257 Scope::WorkflowFilesWrite,
Merge checks: statuses and check runs on every commit258 Scope::ChecksRead,
259 Scope::ChecksWrite,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97260 Scope::DeploymentsRead,
261 Scope::DeploymentsWrite,
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.262 Scope::ObservabilityRead,
263 Scope::ObservabilityWrite,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0264 Scope::MemoryRead,
265 Scope::MemoryWrite,
266 Scope::AccountRead,
267 Scope::AccountWrite,
API: notifications over REST and MCP, with notifications scopes268 Scope::NotificationsRead,
269 Scope::NotificationsWrite,
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)270 Scope::ChatRead,
271 Scope::ChatWrite,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0272 Scope::WorkspaceRead,
273 Scope::WorkspaceAdmin,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit274 Scope::BillingRead,
275 Scope::BillingWrite,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0276 Scope::AccessRead,
277 Scope::AccessAdmin,
278 Scope::WebhooksRead,
279 Scope::WebhooksAdmin,
280 Scope::SecretsRead,
281 Scope::SecretsAdmin,
282 Scope::RunnersRead,
283 Scope::RunnersAdmin,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens284 Scope::ModelsRead,
285 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 yet286 Scope::ArtifactsRead,
287 Scope::ArtifactsWrite,
288 Scope::ArtifactsAdmin,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0289 ];
290
291 pub fn as_str(self) -> &'static str {
292 match self {
293 Scope::RepoRead => "repo:read",
294 Scope::RepoWrite => "repo:write",
295 Scope::RepoAdmin => "repo:admin",
296 Scope::CodeRead => "code:read",
297 Scope::CodeWrite => "code:write",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar298 Scope::SecurityRead => "security:read",
299 Scope::SecurityWrite => "security:write",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member300 Scope::PackagesRead => "packages:read",
301 Scope::PackagesWrite => "packages:write",
302 Scope::PackagesDelete => "packages:delete",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0303 Scope::IssuesRead => "issues:read",
304 Scope::IssuesWrite => "issues:write",
305 Scope::PullRequestsRead => "pull_requests:read",
306 Scope::PullRequestsWrite => "pull_requests:write",
307 Scope::AgentsRun => "agents:run",
308 Scope::WorkflowsRead => "workflows:read",
309 Scope::WorkflowsWrite => "workflows:write",
Token reach: workflow_files scope, fine-grained reach, workspace token cap310 Scope::WorkflowFilesWrite => "workflow_files:write",
Merge checks: statuses and check runs on every commit311 Scope::ChecksRead => "checks:read",
312 Scope::ChecksWrite => "checks:write",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97313 Scope::DeploymentsRead => "deployments:read",
314 Scope::DeploymentsWrite => "deployments:write",
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.315 Scope::ObservabilityRead => "observability:read",
316 Scope::ObservabilityWrite => "observability:write",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0317 Scope::MemoryRead => "memory:read",
318 Scope::MemoryWrite => "memory:write",
319 Scope::AccountRead => "account:read",
320 Scope::AccountWrite => "account:write",
API: notifications over REST and MCP, with notifications scopes321 Scope::NotificationsRead => "notifications:read",
322 Scope::NotificationsWrite => "notifications:write",
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)323 Scope::ChatRead => "chat:read",
324 Scope::ChatWrite => "chat:write",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0325 Scope::WorkspaceRead => "workspace:read",
326 Scope::WorkspaceAdmin => "workspace:admin",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit327 Scope::BillingRead => "billing:read",
328 Scope::BillingWrite => "billing:write",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0329 Scope::AccessRead => "access:read",
330 Scope::AccessAdmin => "access:admin",
331 Scope::WebhooksRead => "webhooks:read",
332 Scope::WebhooksAdmin => "webhooks:admin",
333 Scope::SecretsRead => "secrets:read",
334 Scope::SecretsAdmin => "secrets:admin",
335 Scope::RunnersRead => "runners:read",
336 Scope::RunnersAdmin => "runners:admin",
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens337 Scope::ModelsRead => "models:read",
338 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 yet339 Scope::ArtifactsRead => "artifacts:read",
340 Scope::ArtifactsWrite => "artifacts:write",
341 Scope::ArtifactsAdmin => "artifacts:admin",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0342 }
343 }
344
345 pub fn parse(text: &str) -> Option<Scope> {
346 let text = text.trim().to_ascii_lowercase();
347 Scope::ALL.into_iter().find(|scope| scope.as_str() == text)
348 }
349
350 pub fn resource(self) -> Resource {
351 let name = self.as_str().split_once(':').map_or("", |(resource, _)| resource);
352 Resource::ALL
353 .into_iter()
354 .find(|resource| resource.as_str() == name)
355 .unwrap_or(Resource::Account)
356 }
357
358 pub fn level(self) -> Level {
359 match self.as_str().rsplit_once(':').map_or("", |(_, level)| level) {
360 "write" => Level::Write,
361 "run" => Level::Run,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member362 "delete" => Level::Delete,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0363 "admin" => Level::Admin,
364 _ => Level::Read,
365 }
366 }
367
368 /// Whether holding `self` gives `other`: the same resource, at the same
369 /// level or a lower one.
370 pub fn includes(self, other: Scope) -> bool {
371 self.resource() == other.resource() && self.level() >= other.level()
372 }
373
374 /// Changes that are hard or impossible to undo, or that decide who can
375 /// reach what. Shown behind a warning wherever scopes are chosen.
376 pub fn dangerous(self) -> bool {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member377 matches!(self.level(), Level::Admin | Level::Delete)
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0378 }
379
380 /// What it lets a token do, in plain words.
381 pub fn describe(self) -> &'static str {
382 match self {
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97383 Scope::RepoRead => "See repositories, their settings, labels, timelines, releases, languages, contributors and security alerts, and search",
The Branches page does things to branches now. It has tabs (Overview, Yours, Active, Stale, All, kept in the address as ?tab=) and search; Overview shows the default branch, then the first five of your branches, the active ones and the stale ones, each linking to the rest. Each branch is a row with its name in a code chip with a copy button, who made its latest commit and when, that commit's checks, behind and ahead counts with bars drawn against the largest shown, and its pull request (the open one, else the newest merged or closed) with its state; on a phone the columns fold under the name. Yours is branches whose latest commit is the viewer's; stale is no commit in 90 days.384 Scope::RepoWrite => "Create repositories, create, rename and delete branches, change how pull requests merge and publish releases",
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge385 Scope::RepoAdmin => "Rename, archive, transfer, delete or change who can see a repository, change its rulesets, and dismiss security alerts",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0386 Scope::CodeRead => "Clone and fetch private repositories with git",
387 Scope::CodeWrite => "Push commits with git",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar388 Scope::SecurityRead => "See secret scanning, code scanning and vulnerability alerts, custom patterns, the dependency graph and SBOM, and security settings",
389 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 member390 Scope::PackagesRead => "Pull container images and install private packages",
391 Scope::PackagesWrite => "Push container images and publish packages",
Merge packages: roles, Actions access, source label, soft delete, API392 Scope::PackagesDelete => "Delete and restore packages and their versions",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0393 Scope::IssuesRead => "Read issues, comments and plans",
394 Scope::IssuesWrite => "Open, edit, close and comment on issues",
395 Scope::PullRequestsRead => "Read pull requests, their changes, sessions and merge queues",
396 Scope::PullRequestsWrite => "Open, review, close and merge pull requests",
397 Scope::AgentsRun => "Put g1t agents to work and message them, which uses the workspace's money",
398 Scope::WorkflowsRead => "Read workflows, runs and logs",
399 Scope::WorkflowsWrite => "Run, cancel, rerun and turn workflows on or off",
Token reach: workflow_files scope, fine-grained reach, workspace token cap400 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 commit401 Scope::ChecksRead => "Read commits' statuses, check runs, check suites and annotations",
402 Scope::ChecksWrite => "Report statuses and check runs on commits, and ask for checks to run again",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97403 Scope::DeploymentsRead => "See deployments, their statuses and environments",
404 Scope::DeploymentsWrite => "Report deployments and their statuses, from any CI",
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.405 Scope::ObservabilityRead => "See projects' requests, logs, traces, errors and metrics, and their observability settings, ingest tokens and exports",
406 Scope::ObservabilityWrite => "Resolve and ignore errors, and change projects' observability settings, ingest tokens and exports",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0407 Scope::MemoryRead => "Recall memory and search the workspace's context",
408 Scope::MemoryWrite => "Save memory for the next agent",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97409 Scope::AccountRead => "Read your email addresses, invites, invitations, pinned projects and stars",
410 Scope::AccountWrite => "Change your email addresses, make invites, answer invitations, pin projects and star repositories",
API: notifications over REST and MCP, with notifications scopes411 Scope::NotificationsRead => "See your inbox, its threads, and what you subscribe to and watch",
412 Scope::NotificationsWrite => "Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories",
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)413 Scope::ChatRead => "Read the conversations you are in: channels, direct messages, threads and reactions, live",
414 Scope::ChatWrite => "Send, edit and delete your messages, react, mark conversations read, and answer cards in chat",
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge415 Scope::WorkspaceRead => "Read workspace settings, invites, integrations, model routes, teams and rulesets",
416 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 audit417 Scope::BillingRead => "See a workspace's usage, budget, AI credit and invoices",
418 Scope::BillingWrite => "Change a workspace's budget and buy AI credit",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0419 Scope::AccessRead => "See who has access to repositories",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar420 Scope::AccessAdmin => "Give and take away access to repositories, a team's included",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0421 Scope::WebhooksRead => "See webhooks and their deliveries",
422 Scope::WebhooksAdmin => "Create, change and delete webhooks",
423 Scope::SecretsRead => "List secrets (never their values) and read variables",
424 Scope::SecretsAdmin => "Set and delete secrets and variables",
425 Scope::RunnersRead => "See self-hosted runners, their groups and where agents run",
426 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 tokens427 Scope::ModelsRead => "See the workspace's AI Gateway requests: their models, tokens, cost and status",
428 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 yet429 Scope::ArtifactsRead => "List, read and search artifacts you can see, their versions, and the numbers their dashboards show",
430 Scope::ArtifactsWrite => "Create, rename, move, edit, trash and restore artifacts, and propose changes to them",
431 Scope::ArtifactsAdmin => "Share artifacts, change who can open them, and delete them for good",
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0432 }
433 }
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 yet434
435 /// Whether tokens are offered it yet: its resource's [`Resource::offered`].
436 pub fn offered(self) -> bool {
437 self.resource().offered()
438 }
439}
440
441/// Every scope tokens are offered, in table order: what OAuth advertises.
442pub fn offered_scopes() -> Vec<Scope> {
443 Scope::ALL.into_iter().filter(|scope| scope.offered()).collect()
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0444}
445
446impl Serialize for Scope {
447 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
448 serializer.serialize_str(self.as_str())
449 }
450}
451
452impl<'de> Deserialize<'de> for Scope {
453 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
454 let text = String::deserialize(deserializer)?;
455 Scope::parse(&text).ok_or_else(|| serde::de::Error::custom(format!("unknown scope {text}")))
456 }
457}
458
459/// Scopes as written in a token's row or an OAuth request: separated by
460/// spaces or commas. Unknown names are left out, so a client asking for a
461/// scope from a newer version gets the rest.
462pub fn parse_scopes(text: &str) -> Vec<Scope> {
463 let mut scopes: Vec<Scope> = text
464 .split(|c: char| c.is_whitespace() || c == ',')
465 .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 yet466 .filter(|scope| scope.offered())
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0467 .collect();
468 normalize(&mut scopes);
469 scopes
470}
471
472/// In table order, without repeats.
473pub fn normalize(scopes: &mut Vec<Scope>) {
474 let given = std::mem::take(scopes);
475 scopes.extend(Scope::ALL.into_iter().filter(|scope| given.contains(scope)));
476}
477
478/// Space-separated, as stored and as OAuth writes them.
479pub fn scopes_text(scopes: &[Scope]) -> String {
480 scopes.iter().map(|scope| scope.as_str()).collect::<Vec<_>>().join(" ")
481}
482
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers483/// Where a resource sits on the token form, and which tokens may hold it.
484#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
485#[serde(rename_all = "snake_case")]
486pub enum ResourceGroup {
487 /// About repositories: what they hold and how they are run.
488 Repository,
489 /// About a workspace itself.
490 Workspace,
491 /// About the person: only a personal token may hold these.
492 Account,
493}
494
495impl ResourceGroup {
496 pub const ALL: [ResourceGroup; 3] = [ResourceGroup::Repository, ResourceGroup::Workspace, ResourceGroup::Account];
497
498 pub fn as_str(self) -> &'static str {
499 match self {
500 ResourceGroup::Repository => "repository",
501 ResourceGroup::Workspace => "workspace",
502 ResourceGroup::Account => "account",
503 }
504 }
505}
506
507impl Resource {
508 pub fn group(self) -> ResourceGroup {
509 match self {
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)510 Resource::Account | Resource::Notifications | Resource::Chat => 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 yet511 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 managers512 _ => ResourceGroup::Repository,
513 }
514 }
515
516 pub fn parse(text: &str) -> Option<Resource> {
517 let text = text.trim().to_ascii_lowercase();
518 Resource::ALL.into_iter().find(|resource| resource.as_str() == text)
519 }
520
521 /// Its scopes, least first.
522 pub fn scopes(self) -> Vec<Scope> {
523 Scope::ALL.into_iter().filter(|scope| scope.resource() == self).collect()
524 }
525}
526
527// --- Permissions --------------------------------------------------------------
528//
529// A token's permissions are its scopes read per resource: each resource
530// at none or one level (`{"issues": "write", "repo": "read"}`). A level
531// includes the ones below it, so the highest scope held of each resource
532// says everything; that is what a token stores. Personal tokens and a
533// workspace's own tokens are made, shown and checked this way alike.
534
535/// The highest scope of each resource held, in table order: the fewest
536/// scopes that give the same access, as tokens store them.
537pub fn top_scopes(scopes: &[Scope]) -> Vec<Scope> {
538 let mut top: Vec<Scope> = Vec::new();
539 for resource in Resource::ALL {
540 if let Some(best) = scopes.iter().filter(|scope| scope.resource() == resource).max_by_key(|scope| scope.level()) {
541 top.push(*best);
542 }
543 }
544 normalize(&mut top);
545 top
546}
547
548/// Every resource at its highest level: all a token can be given.
549pub 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 yet550 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 managers551}
552
553/// Scopes as permissions: each resource held, by name, at its highest
554/// level held.
555pub fn permissions_of(scopes: &[Scope]) -> std::collections::BTreeMap<String, String> {
556 top_scopes(scopes)
557 .into_iter()
558 .map(|scope| (scope.resource().as_str().to_owned(), scope.level().as_str().to_owned()))
559 .collect()
560}
561
562/// Permissions as asked for (`{"issues": "write"}`, `none` or empty left
563/// out) into the scopes a token stores, or why they cannot be. `personal`
564/// is whether the token is a person's: only theirs may hold account ones.
565pub fn resolve_permissions(asked: &std::collections::BTreeMap<String, String>, personal: bool) -> Result<Vec<Scope>, String> {
566 let mut scopes = Vec::new();
567 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 yet568 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 managers569 return Err(format!("There is no permission called {name}."));
570 };
571 let level = level.trim().to_ascii_lowercase();
572 if level.is_empty() || level == "none" {
573 continue;
574 }
575 let Some(scope) = Scope::parse(&format!("{}:{level}", resource.as_str())) else {
576 let levels: Vec<&str> = resource.scopes().iter().map(|scope| scope.level().as_str()).collect();
577 return Err(format!("{} is none or {}, not {level}.", resource.as_str(), levels.join(", ")));
578 };
579 if resource.group() == ResourceGroup::Account && !personal {
580 return Err(format!("{} is about a person's account: a workspace's token cannot hold it.", resource.as_str()));
581 }
582 scopes.push(scope);
583 }
584 Ok(top_scopes(&scopes))
585}
586
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0587/// What a token stores for full access, which is not a scope a client can
588/// ask for by name.
589pub const FULL_ACCESS: &str = "*";
590
591/// Starting points for choosing scopes.
592#[derive(Clone, Copy, Debug, PartialEq, Eq)]
593pub enum Preset {
594 ReadOnly,
595 Agent,
596 Ci,
597 Full,
598}
599
600impl Preset {
601 pub const ALL: [Preset; 4] = [Preset::ReadOnly, Preset::Agent, Preset::Ci, Preset::Full];
602
603 pub fn as_str(self) -> &'static str {
604 match self {
605 Preset::ReadOnly => "read_only",
606 Preset::Agent => "agent",
607 Preset::Ci => "ci",
608 Preset::Full => "full",
609 }
610 }
611
612 pub fn label(self) -> &'static str {
613 match self {
614 Preset::ReadOnly => "Read only",
615 Preset::Agent => "Agent",
616 Preset::Ci => "CI",
617 Preset::Full => "Full access",
618 }
619 }
620
621 /// Its scopes; `None` for full access.
622 pub fn scopes(self) -> Option<Vec<Scope>> {
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)623 // Chat is not in any: a person's conversations go to an application
624 // only when it asks for them by name.
625 let reads = || {
626 Scope::ALL
627 .into_iter()
628 .filter(|scope| scope.level() == Level::Read && scope.offered() && scope.resource() != Resource::Chat)
629 };
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0630 match self {
631 Preset::ReadOnly => Some(reads().collect()),
632 Preset::Agent => {
633 // Not the machines work runs on: an agent has no business
634 // knowing a workspace's own runners.
635 let mut scopes: Vec<Scope> = reads().filter(|scope| scope.resource() != Resource::Runners).collect();
API: notifications over REST and MCP, with notifications scopes636 // And answering what needs the person it works for: marking
637 // it done, subscribing, watching.
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0638 scopes.extend([
639 Scope::CodeWrite,
640 Scope::IssuesWrite,
641 Scope::PullRequestsWrite,
642 Scope::AgentsRun,
643 Scope::MemoryWrite,
API: notifications over REST and MCP, with notifications scopes644 Scope::NotificationsWrite,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0645 ]);
646 normalize(&mut scopes);
647 Some(scopes)
648 }
649 Preset::Ci => Some(vec![
650 Scope::RepoRead,
651 Scope::CodeRead,
652 Scope::CodeWrite,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member653 Scope::PackagesRead,
654 Scope::PackagesWrite,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0655 Scope::WorkflowsRead,
656 Scope::WorkflowsWrite,
Merge checks: statuses and check runs on every commit657 Scope::ChecksRead,
658 Scope::ChecksWrite,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97659 Scope::DeploymentsRead,
660 Scope::DeploymentsWrite,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0661 ]),
662 Preset::Full => None,
663 }
664 }
665}
666
667/// What an OAuth client gets when it asks for nothing in particular: the
668/// agent preset. Never an admin scope.
669pub fn oauth_default() -> Vec<Scope> {
670 Preset::Agent.scopes().unwrap_or_default()
671}
672
673/// Set on a [`crate::User`] resolved from an access token: what the token
674/// may do. Absent on a signed-in session, which may do whatever its person
675/// can.
676#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
677pub struct TokenAccess {
678 /// The token's id, as audit entries and errors name it.
679 #[serde(default)]
680 pub token_id: String,
681 /// Its scopes, as `resource:level`. Absent: full access, everything the
682 /// person (or workspace) can do.
683 #[serde(default, skip_serializing_if = "Option::is_none")]
684 pub scopes: Option<Vec<String>>,
685 /// Made before tokens had scopes: full access until someone narrows it.
686 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
687 pub legacy: bool,
Merge branch 'worktree-agent-a3abfcce648e87dca'688 /// Set on a workflow job's token (`G1T_TOKEN`): the one repository it
689 /// reaches, as `owner/name`. Every other is refused, whatever its owner
690 /// could reach.
691 #[serde(default, skip_serializing_if = "Option::is_none")]
692 pub repo: Option<String>,
693 /// Set on a workflow job's token: the run and job it was made for. The
694 /// audit log records its changes as that job's, and what it changes
695 /// starts no workflows (only `workflow_dispatch` and
696 /// `repository_dispatch` do), so a workflow cannot set itself off.
697 #[serde(default, skip_serializing_if = "Option::is_none")]
698 pub job: Option<JobToken>,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens699 /// The token's name, as its owner gave it, so a log can say which
700 /// token made a request. Absent where whoever resolved it did not say.
701 #[serde(default, skip_serializing_if = "Option::is_none")]
702 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 managers703 /// Set on a token narrowed to less than its owner can reach: one
704 /// workspace (all, selected or none of its private repositories), or
705 /// none at all (its owner's account and public repositories). Absent
706 /// on a token that reaches every workspace its owner can.
707 ///
708 /// The wire key is `fine_grained`, kept from before tokens were one
709 /// kind, so services deployed at different moments agree on it.
710 #[serde(rename = "fine_grained", default, skip_serializing_if = "Option::is_none")]
711 pub reach: Option<TokenReach>,
Token reach: workflow_files scope, fine-grained reach, workspace token cap712 /// Set on a workspace's own token that an owner gave Admin when making
713 /// it. Without it a workspace's token has Write on the workspace's
714 /// repositories, as a member would (see [`crate::access`]).
715 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
716 pub admin: bool,
717 /// Set on what a repository's deploy key resolves to: the key's id. Its
718 /// `repo` is the one repository it reaches.
719 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub deploy_key: Option<String>,
Merge main into Artifacts Phase 2721 /// Set on a person's token whose owner let it use the website (g1t.sh)
722 /// as them, sent as `Authorization: Bearer`. Not a scope: no preset,
723 /// full access or OAuth grant includes it, and git, the API and MCP
724 /// ignore it.
725 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
726 pub website: bool,
Token reach: workflow_files scope, fine-grained reach, workspace token cap727}
728
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers729/// Which repositories a token reaches in the workspace it is made for.
Token reach: workflow_files scope, fine-grained reach, workspace token cap730#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
731#[serde(rename_all = "snake_case")]
732pub enum RepositorySelection {
733 /// Every repository of the workspace, ones made later included.
734 #[default]
735 All,
736 /// The repositories chosen, by id.
737 Selected,
738 /// None of the workspace's private repositories: public repositories,
739 /// 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 managers740 /// With no workspace: the owner's account and public repositories only.
Token reach: workflow_files scope, fine-grained reach, workspace token cap741 Public,
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0742}
743
Token reach: workflow_files scope, fine-grained reach, workspace token cap744impl RepositorySelection {
745 pub fn as_str(self) -> &'static str {
746 match self {
747 RepositorySelection::All => "all",
748 RepositorySelection::Selected => "selected",
749 RepositorySelection::Public => "public",
750 }
751 }
752
753 pub fn parse(text: &str) -> Option<RepositorySelection> {
754 match text.trim().to_ascii_lowercase().as_str() {
755 "all" => Some(RepositorySelection::All),
756 "selected" => Some(RepositorySelection::Selected),
757 "public" | "public_only" | "none" => Some(RepositorySelection::Public),
758 _ => None,
759 }
760 }
761}
762
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers763/// What a narrowed token reaches, as identity resolves it on each use.
Token reach: workflow_files scope, fine-grained reach, workspace token cap764#[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 managers765pub struct TokenReach {
766 /// The workspace whose repositories and settings it reaches, by slug as
767 /// it is now. Absent: its owner's account only, with public
768 /// repositories read-only.
Token reach: workflow_files scope, fine-grained reach, workspace token cap769 #[serde(default, skip_serializing_if = "Option::is_none")]
770 pub workspace: Option<String>,
771 #[serde(default)]
772 pub repositories: RepositorySelection,
773 /// With [`RepositorySelection::Selected`]: the repositories' ids.
774 #[serde(default, skip_serializing_if = "Vec::is_empty")]
775 pub repo_ids: Vec<String>,
776}
777
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers778impl TokenReach {
Token reach: workflow_files scope, fine-grained reach, workspace token cap779 /// Whether it reaches the repository with this id in the workspace
780 /// `namespace` for more than what anyone may do with a public one.
781 pub fn covers(&self, repo_id: &str, namespace: &str) -> bool {
782 let Some(workspace) = self.workspace.as_deref() else {
783 return false;
784 };
785 if !workspace.eq_ignore_ascii_case(namespace) {
786 return false;
787 }
788 match self.repositories {
789 RepositorySelection::All => true,
790 RepositorySelection::Selected => self.repo_ids.iter().any(|id| id == repo_id),
791 RepositorySelection::Public => false,
792 }
793 }
794
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers795 /// Whether it is made for the workspace `slug`.
Token reach: workflow_files scope, fine-grained reach, workspace token cap796 pub fn owned_by(&self, slug: &str) -> bool {
797 self.workspace.as_deref().is_some_and(|workspace| workspace.eq_ignore_ascii_case(slug))
798 }
799}
800
801/// Where workflow files live. Adding, changing or deleting a file under
802/// one, with git or through g1t, needs [`Scope::WorkflowFilesWrite`] from a
803/// token: what GitHub's `workflow` scope and `workflows` permission do.
804pub const WORKFLOW_DIRS: [&str; 2] = [".g1t/workflows/", ".github/workflows/"];
805
806/// Whether `path` is a workflow file, or a file in one's directory.
807pub fn is_workflow_file(path: &str) -> bool {
808 let path = path.trim_start_matches('/');
809 WORKFLOW_DIRS.iter().any(|dir| {
810 path.len() >= dir.len() && path.is_char_boundary(dir.len()) && path[..dir.len()].eq_ignore_ascii_case(dir)
811 }) || WORKFLOW_DIRS.iter().any(|dir| path.eq_ignore_ascii_case(dir.trim_end_matches('/')))
812}
813
814/// Whether a token may add, change or delete the files at `paths`: a
815/// refusal naming the first workflow file it may not touch, else `None`.
816/// A signed-in person (no token) is never refused here; their role decides.
817pub fn decide_workflow_files<'a>(access: Option<&TokenAccess>, paths: impl IntoIterator<Item = &'a str>) -> Option<Decision> {
818 let access = access?;
819 if access.allows(Scope::WorkflowFilesWrite) && access.job.is_none() {
820 return None;
821 }
822 let path = paths.into_iter().find(|path| is_workflow_file(path))?;
823 let why = if access.job.is_some() {
824 "a workflow job's token can never add or change workflow files".to_owned()
825 } else {
826 format!("it needs the {} scope", Scope::WorkflowFilesWrite.as_str())
827 };
828 Some(Decision::deny(
829 "token:workflows",
830 format!("This access token cannot change the workflow file {path}: {why}."),
831 ))
832}
833
Merge branch 'worktree-agent-a3abfcce648e87dca'834/// The workflow job a token was made for.
835#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
836pub struct JobToken {
837 /// The run, `run_…`.
838 pub run_id: String,
839 /// The job, `job_…`.
840 pub job_id: String,
841 /// Whether it may open pull requests and approve them, by its
842 /// repository's and workspace's choice ("Allow g1t Actions to create and
843 /// approve pull requests"). Off unless chosen.
844 #[serde(default)]
845 pub pull_requests: bool,
846}
847
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0848impl TokenAccess {
849 /// Full access to everything: the access tokens made before scopes had.
850 pub fn full() -> Self {
851 TokenAccess::default()
852 }
853
Merge branch 'worktree-agent-a3abfcce648e87dca'854 /// Whether it may reach the repository `owner/name`: every token but a
855 /// workflow job's, which reaches its own repository only.
856 pub fn reaches(&self, repo: &str) -> bool {
857 self.repo.as_deref().is_none_or(|only| only.eq_ignore_ascii_case(repo))
858 }
859
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0860 pub fn is_full(&self) -> bool {
861 self.scopes.is_none()
862 }
863
864 /// The scopes it holds, or `None` for full access.
865 pub fn granted(&self) -> Option<Vec<Scope>> {
866 self.scopes
867 .as_ref()
868 .map(|scopes| scopes.iter().filter_map(|scope| Scope::parse(scope)).collect())
869 }
870
871 pub fn allows(&self, needed: Scope) -> bool {
872 match self.granted() {
873 None => true,
874 Some(granted) => granted.iter().any(|held| held.includes(needed)),
875 }
876 }
Token reach: workflow_files scope, fine-grained reach, workspace token cap877
878 /// 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 managers879 /// more than reading a public one: every token but a narrowed one
880 /// outside its workspace or repository selection. Its owner's role
Token reach: workflow_files scope, fine-grained reach, workspace token cap881 /// still decides; see [`crate::access`].
882 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 managers883 self.reach.as_ref().is_none_or(|reach| reach.covers(repo_id, namespace))
Token reach: workflow_files scope, fine-grained reach, workspace token cap884 }
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0885}
886
887/// Every operation of the API and MCP server, with the scope it needs. An
888/// operation in [`NO_SCOPE`] needs none. The API checks that every one of
889/// its operations is in exactly one of the two.
890pub const OPERATIONS: &[(&str, Scope)] = &[
891 // Your account.
892 ("list_emails", Scope::AccountRead),
893 ("add_email", Scope::AccountWrite),
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)894 ("confirm_email", Scope::AccountWrite),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0895 ("remove_email", Scope::AccountWrite),
896 ("update_email_settings", Scope::AccountWrite),
897 ("list_invites", Scope::AccountRead),
898 ("create_invite", Scope::AccountWrite),
899 ("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)900 ("list_invitations", Scope::AccountRead),
901 ("accept_invitation", Scope::AccountWrite),
902 ("decline_invitation", Scope::AccountWrite),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0903 ("list_my_repo_invitations", Scope::AccountRead),
904 ("accept_repo_invitation", Scope::AccountWrite),
905 ("decline_repo_invitation", Scope::AccountWrite),
API: pinned projects over REST and MCP906 // Your pinned projects: a preference of your account.
907 ("list_pinned_projects", Scope::AccountRead),
908 ("pin_project", Scope::AccountWrite),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97909 // Your stars: a preference of your account.
910 ("list_starred", Scope::AccountRead),
911 ("check_starred", Scope::AccountRead),
912 ("star_repo", Scope::AccountWrite),
913 ("unstar_repo", Scope::AccountWrite),
API: pinned projects over REST and MCP914 ("unpin_project", Scope::AccountWrite),
915 ("reorder_pinned_projects", Scope::AccountWrite),
API: notifications over REST and MCP, with notifications scopes916 // Your inbox: notifications, subscriptions and watching.
917 ("list_notifications", Scope::NotificationsRead),
918 ("get_notification_thread", Scope::NotificationsRead),
919 ("get_thread_subscription", Scope::NotificationsRead),
920 ("get_repo_subscription", Scope::NotificationsRead),
921 ("list_watched_repos", Scope::NotificationsRead),
922 ("mark_notifications_read", Scope::NotificationsWrite),
923 ("mark_thread_read", Scope::NotificationsWrite),
924 ("mark_thread_done", Scope::NotificationsWrite),
925 ("save_thread", Scope::NotificationsWrite),
926 ("snooze_thread", Scope::NotificationsWrite),
927 ("set_thread_subscription", Scope::NotificationsWrite),
928 ("delete_thread_subscription", Scope::NotificationsWrite),
929 ("set_repo_subscription", Scope::NotificationsWrite),
930 ("delete_repo_subscription", Scope::NotificationsWrite),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0931 // Workspaces, their invites and integrations.
932 ("create_workspace", Scope::WorkspaceAdmin),
933 ("delete_workspace", Scope::WorkspaceAdmin),
Merge branch 'worktree-agent-ad7c6d88d93adc817'934 ("get_workspace", Scope::WorkspaceRead),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0935 ("update_workspace", Scope::WorkspaceAdmin),
Merge main (membership, two-factor, GitHub repo roles) into tokens936 // Its members, and who owns it.
937 ("list_members", Scope::WorkspaceRead),
938 ("update_member", Scope::WorkspaceAdmin),
939 ("remove_member", Scope::WorkspaceAdmin),
940 ("transfer_ownership", Scope::WorkspaceAdmin),
941 ("leave_workspace", Scope::AccountWrite),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0942 ("list_workspace_invites", Scope::WorkspaceRead),
943 ("invite_member", Scope::WorkspaceAdmin),
944 ("revoke_workspace_invite", Scope::WorkspaceAdmin),
945 ("list_integrations", Scope::WorkspaceRead),
946 ("connect_integration", Scope::WorkspaceAdmin),
AI Gateway: OpenAI's format, open models, and your own providers947 ("update_integration", Scope::WorkspaceAdmin),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.0948 ("disconnect_integration", Scope::WorkspaceAdmin),
949 ("test_integration", Scope::WorkspaceAdmin),
950 ("get_model_routes", Scope::WorkspaceRead),
951 ("set_model_routes", Scope::WorkspaceAdmin),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9952 // Customize: the marketplaces a workspace reads, the plugins it installs
953 // and the connectors it signs in. Members see them, owners change them,
954 // and a job's agent token reads the bundle its Claude Code loads.
955 ("list_marketplaces", Scope::WorkspaceRead),
956 ("add_marketplace", Scope::WorkspaceAdmin),
957 ("remove_marketplace", Scope::WorkspaceAdmin),
958 ("refresh_marketplace", Scope::WorkspaceAdmin),
959 ("discover", Scope::WorkspaceRead),
960 ("list_plugins", Scope::WorkspaceRead),
961 ("get_plugin", Scope::WorkspaceRead),
962 ("install_plugin", Scope::WorkspaceAdmin),
963 ("set_plugin_enabled", Scope::WorkspaceAdmin),
964 ("uninstall_plugin", Scope::WorkspaceAdmin),
965 ("update_plugin", Scope::WorkspaceAdmin),
966 ("list_plugin_agents", Scope::WorkspaceRead),
967 ("list_connectors", Scope::WorkspaceRead),
968 ("get_connector", Scope::WorkspaceRead),
969 ("add_connector", Scope::WorkspaceAdmin),
970 ("set_connector_key", Scope::WorkspaceAdmin),
971 ("remove_connector", Scope::WorkspaceAdmin),
972 ("refresh_connector", Scope::WorkspaceAdmin),
973 ("grant_connector", Scope::WorkspaceAdmin),
974 ("revoke_connector", Scope::WorkspaceAdmin),
975 ("customize_bundle", Scope::AgentsRun),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar976 // Teams: reading them, and managing them. A team's role on a
977 // repository is who has access.
978 ("list_teams", Scope::WorkspaceRead),
979 ("get_team", Scope::WorkspaceRead),
980 ("list_team_members", Scope::WorkspaceRead),
981 ("list_child_teams", Scope::WorkspaceRead),
982 ("list_team_repos", Scope::WorkspaceRead),
983 ("list_user_teams", Scope::WorkspaceRead),
984 ("create_team", Scope::WorkspaceAdmin),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge985 ("list_workspace_rulesets", Scope::WorkspaceRead),
986 ("get_workspace_ruleset", Scope::WorkspaceRead),
987 ("list_workspace_rule_evaluations", Scope::WorkspaceRead),
988 ("create_workspace_ruleset", Scope::WorkspaceAdmin),
989 ("update_workspace_ruleset", Scope::WorkspaceAdmin),
990 ("delete_workspace_ruleset", Scope::WorkspaceAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar991 ("update_team", Scope::WorkspaceAdmin),
992 ("delete_team", Scope::WorkspaceAdmin),
993 ("set_team_member", Scope::WorkspaceAdmin),
994 ("remove_team_member", Scope::WorkspaceAdmin),
995 ("set_team_review_assignment", Scope::WorkspaceAdmin),
Usage, Billing settings and prepaid AI credit; fixes from the UX audit996 // A workspace's billing: usage, budget, AI credit and invoices.
997 ("get_usage", Scope::BillingRead),
998 ("get_budget", Scope::BillingRead),
999 ("get_ai_credit", Scope::BillingRead),
1000 ("list_invoices", Scope::BillingRead),
1001 ("get_billing_details", Scope::BillingRead),
1002 ("set_budget", Scope::BillingWrite),
1003 ("buy_ai_credit", Scope::BillingWrite),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01004 // Repositories.
1005 ("list_repos", Scope::RepoRead),
1006 ("get_repo", Scope::RepoRead),
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1007 // A repository's files, as clone and fetch need.
1008 ("download_tarball_archive", Scope::CodeRead),
1009 ("download_zipball_archive", Scope::CodeRead),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971010 // Projects follow their repositories.
1011 ("list_projects", Scope::RepoRead),
1012 ("get_project", Scope::RepoRead),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01013 ("search", Scope::RepoRead),
1014 ("list_events", Scope::RepoRead),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971015 // What the default branch says about a repository, who starred it, and
1016 // its releases.
1017 ("get_languages", Scope::RepoRead),
1018 ("list_contributors", Scope::RepoRead),
1019 ("get_license", Scope::RepoRead),
1020 ("list_stargazers", Scope::RepoRead),
1021 ("list_releases", Scope::RepoRead),
1022 ("get_latest_release", Scope::RepoRead),
1023 ("get_release_by_tag", Scope::RepoRead),
1024 ("get_release", Scope::RepoRead),
1025 ("create_release", Scope::RepoWrite),
1026 ("update_release", Scope::RepoWrite),
1027 ("delete_release", Scope::RepoWrite),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01028 ("list_labels", Scope::RepoRead),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1029 ("list_milestones", Scope::RepoRead),
1030 ("get_milestone", Scope::RepoRead),
1031 ("create_label", Scope::IssuesWrite),
1032 ("update_label", Scope::IssuesWrite),
1033 ("delete_label", Scope::IssuesWrite),
1034 ("add_default_labels", Scope::IssuesWrite),
1035 ("create_milestone", Scope::IssuesWrite),
1036 ("update_milestone", Scope::IssuesWrite),
1037 ("delete_milestone", Scope::IssuesWrite),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01038 ("get_repo_settings", Scope::RepoRead),
1039 ("list_check_names", Scope::RepoRead),
1040 ("list_deleted_repos", Scope::RepoRead),
1041 ("list_security_alerts", Scope::RepoRead),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1042 ("get_codeowners_errors", Scope::RepoRead),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01043 ("create_repo", Scope::RepoWrite),
1044 ("update_repo", Scope::RepoWrite),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971045 ("update_project", Scope::RepoWrite),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01046 ("update_repo_settings", Scope::RepoWrite),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1047 // Rulesets: reading them is reading the repository; changing them
1048 // changes what everyone, agents included, may do, so it is admin.
1049 ("list_repo_rulesets", Scope::RepoRead),
1050 ("get_repo_ruleset", Scope::RepoRead),
1051 ("get_branch_rules", Scope::RepoRead),
1052 ("list_rule_evaluations", Scope::RepoRead),
1053 ("create_repo_ruleset", Scope::RepoAdmin),
1054 ("update_repo_ruleset", Scope::RepoAdmin),
1055 ("delete_repo_ruleset", Scope::RepoAdmin),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01056 ("rename_branch", Scope::RepoWrite),
The Branches page does things to branches now. It has tabs (Overview, Yours, Active, Stale, All, kept in the address as ?tab=) and search; Overview shows the default branch, then the first five of your branches, the active ones and the stale ones, each linking to the rest. Each branch is a row with its name in a code chip with a copy button, who made its latest commit and when, that commit's checks, behind and ahead counts with bars drawn against the largest shown, and its pull request (the open one, else the newest merged or closed) with its state; on a phone the columns fold under the name. Yours is branches whose latest commit is the viewer's; stale is no commit in 90 days.1057 ("create_branch", Scope::RepoWrite),
1058 ("delete_branch", Scope::RepoWrite),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01059 ("rename_repo", Scope::RepoAdmin),
1060 ("transfer_repo", Scope::RepoAdmin),
1061 ("archive_repo", Scope::RepoAdmin),
1062 ("unarchive_repo", Scope::RepoAdmin),
1063 ("set_repo_visibility", Scope::RepoAdmin),
1064 ("delete_repo", Scope::RepoAdmin),
1065 ("restore_repo", Scope::RepoAdmin),
1066 ("purge_repo", Scope::RepoAdmin),
1067 // A dismissed secret is let through push protection.
1068 ("dismiss_security_alert", Scope::RepoAdmin),
1069 ("reopen_security_alert", Scope::RepoAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1070 // The security suite: alerts, push protection, patterns, code
1071 // scanning, the supply chain and settings.
1072 ("list_secret_scanning_alerts", Scope::SecurityRead),
1073 ("get_secret_scanning_alert", Scope::SecurityRead),
1074 ("list_secret_scanning_locations", Scope::SecurityRead),
1075 ("list_bypass_requests", Scope::SecurityRead),
1076 ("list_custom_patterns", Scope::SecurityRead),
1077 ("list_code_scanning_alerts", Scope::SecurityRead),
1078 ("get_code_scanning_alert", Scope::SecurityRead),
1079 ("list_code_scanning_analyses", Scope::SecurityRead),
1080 ("get_sarif_upload", Scope::SecurityRead),
1081 ("list_vulnerability_alerts", Scope::SecurityRead),
1082 ("get_vulnerability_alert", Scope::SecurityRead),
1083 ("get_dependency_graph", Scope::SecurityRead),
1084 ("get_sbom", Scope::SecurityRead),
1085 ("compare_dependencies", Scope::SecurityRead),
1086 ("get_security_settings", Scope::SecurityRead),
1087 ("get_workspace_security_settings", Scope::SecurityRead),
1088 ("get_security_overview", Scope::SecurityRead),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r91089 ("list_security_advisories", Scope::SecurityRead),
1090 ("get_security_advisory", Scope::SecurityRead),
1091 ("list_vulnerability_reports", Scope::SecurityRead),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1092 ("update_secret_scanning_alert", Scope::SecurityWrite),
1093 ("bypass_push_protection", Scope::SecurityWrite),
1094 ("check_secret_validity", Scope::SecurityWrite),
1095 ("review_bypass_request", Scope::SecurityWrite),
1096 ("create_custom_pattern", Scope::SecurityWrite),
1097 ("update_custom_pattern", Scope::SecurityWrite),
1098 ("delete_custom_pattern", Scope::SecurityWrite),
1099 ("dry_run_custom_pattern", Scope::SecurityWrite),
1100 ("update_code_scanning_alert", Scope::SecurityWrite),
1101 ("upload_sarif", Scope::SecurityWrite),
1102 ("update_vulnerability_alert", Scope::SecurityWrite),
1103 ("fix_security_alert", Scope::SecurityWrite),
1104 ("update_security_settings", Scope::SecurityWrite),
1105 ("update_workspace_security_settings", Scope::SecurityWrite),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r91106 ("create_security_advisory", Scope::SecurityWrite),
1107 ("update_security_advisory", Scope::SecurityWrite),
1108 ("set_security_advisory_state", Scope::SecurityWrite),
1109 ("report_vulnerability", Scope::SecurityWrite),
1110 ("review_vulnerability_report", Scope::SecurityWrite),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01111 // Issues and plans.
1112 ("list_issues", Scope::IssuesRead),
1113 ("get_issue", Scope::IssuesRead),
1114 ("get_plan", Scope::IssuesRead),
1115 ("create_issue", Scope::IssuesWrite),
1116 ("update_issue", Scope::IssuesWrite),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1117 ("list_issue_labels", Scope::IssuesRead),
1118 ("add_issue_labels", Scope::IssuesWrite),
1119 ("set_issue_labels", Scope::IssuesWrite),
1120 ("remove_issue_labels", Scope::IssuesWrite),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01121 ("close_issue", Scope::IssuesWrite),
1122 ("reopen_issue", Scope::IssuesWrite),
1123 ("add_comment", Scope::IssuesWrite),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1124 ("edit_comment", Scope::IssuesWrite),
1125 ("delete_comment", Scope::IssuesWrite),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01126 ("import_issue", Scope::IssuesWrite),
1127 ("apply_plan", Scope::IssuesWrite),
1128 // Pull requests.
1129 ("list_pull_requests", Scope::PullRequestsRead),
1130 ("get_pull_request", Scope::PullRequestsRead),
1131 ("get_pull_request_changes", Scope::PullRequestsRead),
1132 ("read_session", Scope::PullRequestsRead),
1133 ("get_merge_queue", Scope::PullRequestsRead),
1134 ("create_pull_request", Scope::PullRequestsWrite),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1135 ("update_pull_request", Scope::PullRequestsWrite),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01136 ("record_session", Scope::PullRequestsWrite),
1137 ("mark_pull_request_ready", Scope::PullRequestsWrite),
1138 ("close_pull_request", Scope::PullRequestsWrite),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1139 ("reopen_pull_request", Scope::PullRequestsWrite),
1140 ("convert_pull_request_to_draft", Scope::PullRequestsWrite),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01141 ("review_pull_request", Scope::PullRequestsWrite),
1142 ("merge_pull_request", Scope::PullRequestsWrite),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1143 ("request_reviewers", Scope::PullRequestsWrite),
1144 ("remove_requested_reviewers", Scope::PullRequestsWrite),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01145 // g1t's agents.
1146 ("assign_issue", Scope::AgentsRun),
1147 ("delegate", Scope::AgentsRun),
1148 ("plan_work", Scope::AgentsRun),
1149 ("message_agent", Scope::AgentsRun),
1150 ("answer_message", Scope::AgentsRun),
Every agent can have its own computer. A session that needs one wakes it: a home of its own on g1t cloud, one per agent and never shared, where it runs commands, reads and writes files and keeps what it made, with each session working in its own folder under a shared home; after ten idle minutes it sleeps, its home kept as a snapshot and restored when it wakes, and Reset wipes the home while memory and artifacts stay. Its shell and files are abilities with the usual choices, Alone, Alone when asked, Ask first or Never, offered only inside sessions and never to a chat reply; every command shows on the session with its output, and the agent's new Computer tab shows the state, the disk used of the five gigabytes included, the recent commands, and Wake, Put to sleep and Reset. Machine time counts only while it is awake, on the sandbox lines of the ledger that name the agent and who asked, held to the same spend caps as the session; the disk itself costs nothing in this version. The runner gained a long-lived supervisor that answers the computer's requests inside the container, and the runner service a computer per agent that keeps its snapshot in the agent homes bucket when one is attached, and says so when none is. The REST API and the agent tool can read a computer, wake it, put it to sleep and reset it. The agents, abilities, sessions, runners, billing and deploy guides say how it works and what an operator sets up; pinning a computer to your own runner, its browser and take-over come next.1151 // A workspace agent's own computer: waking it spends the workspace's
1152 // money, so the same scope covers reading and managing it.
1153 ("get_agent_computer", Scope::AgentsRun),
1154 ("wake_agent_computer", Scope::AgentsRun),
1155 ("sleep_agent_computer", Scope::AgentsRun),
1156 ("reset_agent_computer", Scope::AgentsRun),
1157 ("list_agent_computer_commands", Scope::AgentsRun),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01158 ("take_messages", Scope::AgentsRun),
1159 // Workflows.
1160 ("list_workflows", Scope::WorkflowsRead),
1161 ("list_workflow_runs", Scope::WorkflowsRead),
1162 ("get_workflow_run", Scope::WorkflowsRead),
1163 ("get_job_logs", Scope::WorkflowsRead),
1164 ("dispatch_workflow", Scope::WorkflowsWrite),
1165 ("cancel_workflow_run", Scope::WorkflowsWrite),
1166 ("rerun_workflow_run", Scope::WorkflowsWrite),
1167 ("update_workflow", Scope::WorkflowsWrite),
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R21168 ("list_artifacts", Scope::WorkflowsRead),
1169 ("list_workflow_run_artifacts", Scope::WorkflowsRead),
1170 ("get_artifact", Scope::WorkflowsRead),
1171 ("download_artifact", Scope::WorkflowsRead),
1172 ("get_artifact_retention", Scope::WorkflowsRead),
1173 ("delete_artifact", Scope::WorkflowsWrite),
1174 ("set_artifact_retention", Scope::WorkflowsWrite),
Merge checks: statuses and check runs on every commit1175 // Checks: statuses, check runs and check suites on commits.
1176 ("list_commit_statuses", Scope::ChecksRead),
1177 ("get_combined_status", Scope::ChecksRead),
1178 ("list_check_runs_for_ref", Scope::ChecksRead),
1179 ("get_check_run", Scope::ChecksRead),
1180 ("list_check_run_annotations", Scope::ChecksRead),
1181 ("list_check_suites_for_ref", Scope::ChecksRead),
1182 ("get_check_suite", Scope::ChecksRead),
1183 ("create_commit_status", Scope::ChecksWrite),
1184 ("create_check_run", Scope::ChecksWrite),
1185 ("update_check_run", Scope::ChecksWrite),
1186 ("rerequest_check_run", Scope::ChecksWrite),
1187 ("rerequest_check_suite", Scope::ChecksWrite),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971188 // Deployments, wherever they run: reading them, and reporting them.
1189 ("list_deployments", Scope::DeploymentsRead),
1190 ("get_deployment", Scope::DeploymentsRead),
1191 ("list_deployment_statuses", Scope::DeploymentsRead),
1192 ("list_environments", Scope::DeploymentsRead),
1193 ("get_environment", Scope::DeploymentsRead),
1194 ("create_deployment", Scope::DeploymentsWrite),
1195 ("create_deployment_status", Scope::DeploymentsWrite),
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91196 ("start_deployment_build", Scope::DeploymentsWrite),
Projects have Observability: their requests, logs, traces, metrics and errors, from apps on g1t.page with nothing to set up, and from software anywhere else over OpenTelemetry.1197 // Observability: what a project's software did, and how it is collected.
1198 ("get_observability_overview", Scope::ObservabilityRead),
1199 ("list_requests", Scope::ObservabilityRead),
1200 ("get_request", Scope::ObservabilityRead),
1201 ("list_logs", Scope::ObservabilityRead),
1202 ("list_traces", Scope::ObservabilityRead),
1203 ("get_trace", Scope::ObservabilityRead),
1204 ("list_errors", Scope::ObservabilityRead),
1205 ("list_metrics", Scope::ObservabilityRead),
1206 ("get_metric", Scope::ObservabilityRead),
1207 ("get_observability_settings", Scope::ObservabilityRead),
1208 ("list_ingest_tokens", Scope::ObservabilityRead),
1209 ("list_telemetry_exports", Scope::ObservabilityRead),
1210 ("get_observability_usage", Scope::ObservabilityRead),
1211 ("update_error", Scope::ObservabilityWrite),
1212 ("update_observability_settings", Scope::ObservabilityWrite),
1213 ("create_ingest_token", Scope::ObservabilityWrite),
1214 ("delete_ingest_token", Scope::ObservabilityWrite),
1215 ("create_telemetry_export", Scope::ObservabilityWrite),
1216 ("update_telemetry_export", Scope::ObservabilityWrite),
1217 ("delete_telemetry_export", Scope::ObservabilityWrite),
Merge branch 'worktree-agent-a3abfcce648e87dca'1218 // What keeps runs safe: the runs environments hold and reviewing them,
1219 // approving a pull request's run, and a repository's own rules for
1220 // its environments and tokens, which are an admin's.
1221 ("get_pending_deployments", Scope::WorkflowsRead),
1222 ("review_pending_deployments", Scope::WorkflowsWrite),
1223 ("approve_workflow_run", Scope::WorkflowsWrite),
1224 ("get_workflow_permissions", Scope::RepoRead),
1225 ("get_fork_pr_approval", Scope::RepoRead),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1226 ("get_actions_access", Scope::RepoRead),
Merge branch 'worktree-agent-a3abfcce648e87dca'1227 ("update_environment", Scope::RepoAdmin),
1228 ("delete_environment", Scope::RepoAdmin),
1229 ("set_workflow_permissions", Scope::RepoAdmin),
1230 ("set_fork_pr_approval", Scope::RepoAdmin),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1231 ("set_actions_access", Scope::RepoAdmin),
Merge branch 'worktree-agent-a3abfcce648e87dca'1232 // Starting workflows from outside, as a push would.
1233 ("create_repository_dispatch", Scope::CodeWrite),
1234 // A workspace's policy for its repositories' tokens.
1235 ("get_workspace_workflow_permissions", Scope::WorkspaceRead),
1236 ("set_workspace_workflow_permissions", Scope::WorkspaceAdmin),
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.1237 // The workflows a workspace runs in its repositories: its owners'.
1238 ("list_workspace_managed_workflows", Scope::WorkspaceRead),
1239 ("create_workspace_managed_workflow", Scope::WorkspaceAdmin),
1240 ("update_workspace_managed_workflow", Scope::WorkspaceAdmin),
1241 ("delete_workspace_managed_workflow", Scope::WorkspaceAdmin),
API and MCP for a workspace's personal access token rules, members' tokens and approvals1242 // A workspace's rules for personal access tokens, and the members'
1243 // tokens that reach it: who has access.
1244 ("get_token_policy", Scope::WorkspaceRead),
1245 ("set_token_policy", Scope::WorkspaceAdmin),
1246 ("list_member_tokens", Scope::AccessRead),
1247 ("list_token_requests", Scope::AccessRead),
1248 ("review_token_request", Scope::AccessAdmin),
1249 ("revoke_member_token", Scope::AccessAdmin),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01250 // Memory and the context hub.
1251 ("recall", Scope::MemoryRead),
1252 ("search_context", Scope::MemoryRead),
1253 ("get_entity", Scope::MemoryRead),
1254 ("get_context", Scope::MemoryRead),
1255 ("remember", Scope::MemoryWrite),
1256 // Who has access.
1257 ("list_collaborators", Scope::AccessRead),
1258 ("get_collaborator_permission", Scope::AccessRead),
1259 ("list_repo_invitations", Scope::AccessRead),
1260 ("list_outside_collaborators", Scope::AccessRead),
1261 ("add_collaborator", Scope::AccessAdmin),
1262 ("update_collaborator", Scope::AccessAdmin),
1263 ("remove_collaborator", Scope::AccessAdmin),
1264 ("revoke_repo_invitation", Scope::AccessAdmin),
1265 ("set_base_permission", Scope::AccessAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1266 ("set_team_repo", Scope::AccessAdmin),
1267 ("remove_team_repo", Scope::AccessAdmin),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1268 // Deploy keys: each lets a machine reach one repository, so they
1269 // are part of who has access.
1270 ("list_deploy_keys", Scope::AccessRead),
1271 ("get_deploy_key", Scope::AccessRead),
1272 ("create_deploy_key", Scope::AccessAdmin),
1273 ("delete_deploy_key", Scope::AccessAdmin),
Merge branch 'mirroring' into artifacts-mode1274 // Mirroring: a repository's links to other hosts. Reading them is
1275 // reading the repository; syncing writes code; the rest is an admin's.
1276 ("get_mirror", Scope::RepoRead),
1277 ("sync_mirror", Scope::CodeWrite),
1278 ("get_hand_back_plan", Scope::RepoAdmin),
1279 ("take_over_mirror", Scope::RepoAdmin),
1280 ("set_ci_failover", Scope::RepoAdmin),
1281 ("hand_back_mirror", Scope::RepoAdmin),
1282 ("move_mirror_to_g1t", Scope::RepoAdmin),
1283 ("add_mirror_remote", Scope::RepoAdmin),
1284 ("update_mirror_remote", Scope::RepoAdmin),
1285 ("remove_mirror_remote", Scope::RepoAdmin),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01286 // Webhooks.
1287 ("list_webhooks", Scope::WebhooksRead),
1288 ("list_webhook_deliveries", Scope::WebhooksRead),
1289 ("create_webhook", Scope::WebhooksAdmin),
1290 ("update_webhook", Scope::WebhooksAdmin),
1291 ("delete_webhook", Scope::WebhooksAdmin),
1292 ("ping_webhook", Scope::WebhooksAdmin),
1293 ("redeliver_webhook", Scope::WebhooksAdmin),
1294 // Secrets and variables.
1295 ("list_actions_secrets", Scope::SecretsRead),
1296 ("list_actions_variables", Scope::SecretsRead),
1297 ("set_actions_secret", Scope::SecretsAdmin),
1298 ("delete_actions_secret", Scope::SecretsAdmin),
1299 ("set_actions_variable", Scope::SecretsAdmin),
1300 ("delete_actions_variable", Scope::SecretsAdmin),
1301 // Self-hosted runners.
1302 ("list_runners", Scope::RunnersRead),
1303 ("list_runner_groups", Scope::RunnersRead),
1304 ("get_runner_settings", Scope::RunnersRead),
1305 ("create_runner_registration_token", Scope::RunnersAdmin),
1306 ("remove_runner", Scope::RunnersAdmin),
1307 ("create_runner_group", Scope::RunnersAdmin),
1308 ("update_runner_group", Scope::RunnersAdmin),
1309 ("delete_runner_group", Scope::RunnersAdmin),
1310 ("update_runner_settings", Scope::RunnersAdmin),
Merge packages: roles, Actions access, source label, soft delete, API1311 // Packages: reading them, their versions and who may use them needs
1312 // `packages:read`; changing their settings, access and Manage Actions
1313 // access `packages:write` (and the Admin role on the package, which the
1314 // packages service checks); deleting and restoring packages and
1315 // versions `packages:delete`, as the registries' own deletes do.
1316 ("list_packages", Scope::PackagesRead),
1317 ("get_package", Scope::PackagesRead),
1318 ("list_package_versions", Scope::PackagesRead),
1319 ("get_package_version", Scope::PackagesRead),
1320 ("list_package_access", Scope::PackagesRead),
1321 ("list_package_actions_access", Scope::PackagesRead),
1322 ("update_package", Scope::PackagesWrite),
1323 ("link_package", Scope::PackagesWrite),
1324 ("unlink_package", Scope::PackagesWrite),
1325 ("set_package_access", Scope::PackagesWrite),
1326 ("remove_package_access", Scope::PackagesWrite),
1327 ("set_package_actions_access", Scope::PackagesWrite),
1328 ("remove_package_actions_access", Scope::PackagesWrite),
1329 ("delete_package", Scope::PackagesDelete),
1330 ("restore_package", Scope::PackagesDelete),
1331 ("delete_package_version", Scope::PackagesDelete),
1332 ("restore_package_version", Scope::PackagesDelete),
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1333 // The AI Gateway. Sending a request to a model needs `models:write`,
1334 // checked by the model proxy at models.g1t.sh, not here.
1335 ("list_gateway_requests", Scope::ModelsRead),
Merge main into Artifacts Phase 21336 // Artifacts mode's docs, slides, designs and dashboards (the `artifact`
1337 // MCP tool). Reading takes artifacts:read, making and changing them
1338 // artifacts:write, and sharing them or deleting them for good
The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook.1339 // artifacts:admin. The artifacts service then checks the person's own role
Merge main into Artifacts Phase 21340 // on each one.
1341 ("list_workspace_artifacts", Scope::ArtifactsRead),
1342 ("search_workspace_artifacts", Scope::ArtifactsRead),
1343 ("get_workspace_artifact", Scope::ArtifactsRead),
1344 ("get_workspace_artifact_content", Scope::ArtifactsRead),
1345 ("list_workspace_artifact_versions", Scope::ArtifactsRead),
1346 ("get_workspace_artifact_access", Scope::ArtifactsRead),
1347 ("list_workspace_artifact_templates", Scope::ArtifactsRead),
1348 ("list_workspace_artifact_spaces", Scope::ArtifactsRead),
1349 ("query_workspace_dataset", Scope::ArtifactsRead),
1350 ("create_workspace_artifact", Scope::ArtifactsWrite),
1351 ("update_workspace_artifact", Scope::ArtifactsWrite),
1352 ("edit_workspace_artifact", Scope::ArtifactsWrite),
1353 ("trash_workspace_artifact", Scope::ArtifactsWrite),
1354 ("restore_workspace_artifact", Scope::ArtifactsWrite),
1355 ("restore_workspace_artifact_version", Scope::ArtifactsWrite),
1356 ("set_workspace_artifact_access", Scope::ArtifactsAdmin),
1357 ("purge_workspace_artifact", Scope::ArtifactsAdmin),
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01358];
1359
1360/// Operations any token may use: saying who it is.
1361pub const NO_SCOPE: &[&str] = &["whoami"];
1362
1363/// The scope `operation` needs. `None` for one in [`NO_SCOPE`]; an
1364/// operation in neither list needs full access.
1365pub fn scope_for(operation: &str) -> Option<Scope> {
1366 OPERATIONS
1367 .iter()
1368 .find(|(name, _)| *name == operation)
1369 .map(|(_, scope)| *scope)
1370}
1371
1372/// What a token needs for `operation` with this input beyond its own
1373/// scope: starting agents from an operation that can, and making a
1374/// repository public or private.
1375pub fn extra_scopes(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1376 let mut extra = Vec::new();
1377 let assigns = input["assign"].as_bool() == Some(true)
1378 || input["agent"].as_bool() == Some(true)
1379 || input["assign_agent"].as_bool() == Some(true);
1380 if assigns && matches!(operation, "apply_plan" | "import_issue" | "create_issue") {
1381 extra.push(Scope::AgentsRun);
1382 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1383 // Fixing an alert opens an issue and puts g1t on it.
1384 if operation == "fix_security_alert" {
1385 extra.extend([Scope::IssuesWrite, Scope::AgentsRun]);
1386 }
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01387 // Opening the issue an agent is put on.
1388 if operation == "delegate" {
1389 extra.push(Scope::IssuesWrite);
1390 }
1391 // A workspace's base permission is who has access.
1392 if operation == "update_workspace" && input.get("base_permission").is_some_and(|v| !v.is_null()) {
1393 extra.push(Scope::AccessAdmin);
1394 }
Merge checks: statuses and check runs on every commit1395 // Asking a g1t Actions job or run to run again reruns its workflow.
1396 if matches!(operation, "rerequest_check_run" | "rerequest_check_suite")
1397 && input["id"].as_str().is_some_and(|id| id.starts_with("job_") || id.starts_with("run_"))
1398 {
1399 extra.push(Scope::WorkflowsWrite);
1400 }
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01401 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())) {
1402 extra.push(Scope::RepoAdmin);
1403 }
1404 extra
1405}
1406
1407/// The scopes a call needs, its own first.
1408pub fn needed(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1409 scope_for(operation)
1410 .into_iter()
1411 .chain(extra_scopes(operation, input))
1412 .collect()
1413}
1414
1415/// Whether `access` may use `operation` with `input`. The person's (or
1416/// workspace's) role is checked after this, by the service that owns what
1417/// was asked about.
1418pub fn decide(access: &TokenAccess, operation: &str, input: &serde_json::Value) -> Decision {
1419 let rule = if access.legacy { "token:legacy" } else { "token:scope" };
Merge branch 'worktree-agent-a3abfcce648e87dca'1420 // A workflow job may open or approve pull requests only where its
1421 // repository and workspace let it, as on GitHub.
1422 if let Some(job) = &access.job
1423 && !job.pull_requests
1424 && (operation == "create_pull_request" || (operation == "review_pull_request" && input["verdict"].as_str() == Some("approve")))
1425 {
1426 return Decision::deny(
1427 "token:pull-requests",
1428 "A workflow job cannot open or approve pull requests here: an admin can allow it under Settings, Actions.",
1429 );
1430 }
1431 if let Some(only) = access.repo.as_deref()
1432 && !NO_SCOPE.contains(&operation)
1433 {
1434 match input["repo"].as_str() {
1435 Some(repo) if access.reaches(repo) => {}
1436 Some(repo) => {
1437 return Decision::deny("token:repository", format!("This token is a workflow job's in {only}: it cannot reach {repo}."));
1438 }
1439 None => {
1440 return Decision::deny("token:repository", format!("This token is a workflow job's: it reaches only {only}, and {operation} is not about one repository."));
1441 }
1442 }
1443 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1444 // A token made for one workspace (or none) only reads outside it:
1445 // public repositories, as anyone may. Inside it, its repository
1446 // selection is checked with its owner's role (`access::granted`).
1447 if let Some(reach) = &access.reach
Token reach: workflow_files scope, fine-grained reach, workspace token cap1448 && let Some(repo) = input["repo"].as_str()
1449 && !NO_SCOPE.contains(&operation)
1450 {
1451 let namespace = repo.split('/').next().unwrap_or_default();
1452 let changes = needed(operation, input).iter().any(|scope| scope.level() != Level::Read);
1453 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 managers1454 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 cap1455 return Decision::deny(
1456 "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 managers1457 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 cap1458 );
1459 }
1460 }
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01461 if access.scopes.is_some() {
1462 let known = NO_SCOPE.contains(&operation) || scope_for(operation).is_some();
1463 if !known {
1464 return Decision::deny("token:scope", format!("This access token cannot use {operation}: it needs full access."));
1465 }
1466 if let Some(missing) = needed(operation, input).into_iter().find(|scope| !access.allows(*scope)) {
1467 return Decision::deny(
1468 "token:scope",
1469 format!("This access token needs the {} scope to use {operation}.", missing.as_str()),
1470 );
1471 }
1472 }
1473 Decision::allow(rule)
1474}
1475
Merge branch 'worktree-agent-a3abfcce648e87dca'1476/// Whether a token may use the repository `owner/name` at all: a refusal
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1477/// for a workflow job's token or a deploy key in another repository,
1478/// else `None`. Git and
Merge branch 'worktree-agent-a3abfcce648e87dca'1479/// the package registries ask this before [`decide_git`] and
1480/// [`decide_packages`].
1481pub fn decide_repo(access: &TokenAccess, repo: &str) -> Option<Decision> {
1482 let only = access.repo.as_deref()?;
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1483 let why = if access.deploy_key.is_some() {
1484 format!("This deploy key is for {only}: it cannot reach {repo}.")
1485 } else {
1486 format!("This token is a workflow job's in {only}: it cannot reach {repo}.")
1487 };
1488 (!access.reaches(repo)).then(|| Decision::deny("token:repository", why))
Merge branch 'worktree-agent-a3abfcce648e87dca'1489}
1490
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01491/// Whether a token may clone or fetch (`write` false), or push to (`write`
1492/// true), a repository with git. `public` is whether anyone may read it,
1493/// which needs no scope.
1494pub fn decide_git(access: &TokenAccess, write: bool, public: bool) -> Decision {
1495 let needed = if write { Scope::CodeWrite } else { Scope::CodeRead };
1496 if !access.allows(needed) && (write || !public) {
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1497 if access.deploy_key.is_some() {
1498 return Decision::deny(
1499 "token:scope",
1500 "This deploy key is read-only. An admin of the repository can add it again with write access to push with it.",
1501 );
1502 }
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01503 return Decision::deny(
1504 "token:scope",
1505 format!("This access token needs the {} scope to {} with git.", needed.as_str(), if write { "push" } else { "clone or fetch a private repository" }),
1506 );
1507 }
1508 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1509}
1510
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1511/// Whether a token may pull (`Level::Read`), push or publish
1512/// (`Level::Write`), or delete (`Level::Delete`) packages. `public` is
1513/// whether anyone may pull the package, which needs no scope.
1514pub fn decide_packages(access: &TokenAccess, level: Level, public: bool) -> Decision {
1515 let (needed, doing) = match level {
1516 Level::Read => (Scope::PackagesRead, "pull a private package"),
1517 Level::Delete | Level::Admin => (Scope::PackagesDelete, "delete packages"),
1518 Level::Write | Level::Run => (Scope::PackagesWrite, "push or publish packages"),
1519 };
1520 if !access.allows(needed) && !(level == Level::Read && public) {
1521 return Decision::deny(
1522 "token:scope",
1523 format!("This access token needs the {} scope to {doing}.", needed.as_str()),
1524 );
1525 }
1526 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1527}
1528
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01529#[cfg(test)]
1530mod tests {
1531 use super::*;
1532 use serde_json::json;
1533
1534 fn token(scopes: &[Scope]) -> TokenAccess {
1535 TokenAccess {
1536 token_id: "tok_1".to_owned(),
1537 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
1538 legacy: false,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1539 name: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1540 ..TokenAccess::default()
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01541 }
1542 }
1543
1544 #[test]
1545 fn every_scope_reads_back_and_belongs_to_a_resource() {
1546 for scope in Scope::ALL {
1547 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
1548 assert!(scope.as_str().starts_with(scope.resource().as_str()));
1549 assert!(scope.includes(scope));
1550 }
1551 assert_eq!(Scope::parse(" Issues:Write "), Some(Scope::IssuesWrite));
1552 assert_eq!(Scope::parse("issues"), None);
1553 }
1554
1555 #[test]
1556 fn a_higher_level_includes_the_lower_ones_of_its_resource_only() {
1557 assert!(Scope::RepoAdmin.includes(Scope::RepoRead));
1558 assert!(Scope::RepoAdmin.includes(Scope::RepoWrite));
1559 assert!(Scope::IssuesWrite.includes(Scope::IssuesRead));
1560 assert!(!Scope::IssuesRead.includes(Scope::IssuesWrite));
1561 assert!(!Scope::RepoAdmin.includes(Scope::CodeWrite));
1562 assert!(!Scope::PullRequestsWrite.includes(Scope::IssuesWrite));
1563 }
1564
1565 #[test]
1566 fn operations_are_listed_once_and_never_also_free() {
1567 let mut seen = std::collections::HashSet::new();
1568 for (name, _) in OPERATIONS {
1569 assert!(seen.insert(*name), "{name} twice");
1570 assert!(!NO_SCOPE.contains(name), "{name}");
1571 }
1572 }
1573
1574 #[test]
1575 fn scopes_are_parsed_from_oauth_text_leaving_out_unknown_ones() {
1576 assert_eq!(
1577 parse_scopes("issues:write repo:read,bogus:thing issues:write"),
1578 vec![Scope::RepoRead, Scope::IssuesWrite]
1579 );
1580 assert_eq!(scopes_text(&[Scope::RepoRead, Scope::IssuesWrite]), "repo:read issues:write");
1581 }
1582
1583 #[test]
1584 fn the_oauth_default_is_the_agent_preset_and_never_admin() {
1585 let scopes = oauth_default();
1586 assert!(scopes.contains(&Scope::IssuesWrite));
1587 assert!(scopes.contains(&Scope::PullRequestsWrite));
1588 assert!(scopes.contains(&Scope::AgentsRun));
1589 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 yet1590 for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read && scope.offered()) {
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)1591 // Every read offered but the machines work runs on, and chat.
1592 assert_eq!(scopes.contains(&read), read != Scope::RunnersRead && read != Scope::ChatRead, "{read:?}");
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01593 }
1594 assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read));
1595 assert_eq!(Preset::Full.scopes(), None);
1596 }
1597
1598 #[test]
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1599 fn billing_is_read_by_presets_and_changed_by_none_but_full_access() {
1600 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::BillingRead));
1601 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1602 assert!(!preset.scopes().unwrap().contains(&Scope::BillingWrite), "{}", preset.as_str());
1603 }
1604 assert_eq!(scope_for("set_budget"), Some(Scope::BillingWrite));
1605 assert_eq!(scope_for("buy_ai_credit"), Some(Scope::BillingWrite));
1606 assert_eq!(scope_for("get_usage"), Some(Scope::BillingRead));
1607 let reader = token(&[Scope::BillingRead]);
1608 assert!(decide(&reader, "list_invoices", &json!({})).allowed);
1609 assert!(decide(&reader, "set_budget", &json!({})).reason.unwrap().contains("billing:write"));
1610 }
1611
1612 #[test]
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1613 fn the_ai_gateway_spends_only_with_models_write_which_no_preset_gives() {
1614 // Reading the log is a read like any other.
1615 assert_eq!(scope_for("list_gateway_requests"), Some(Scope::ModelsRead));
1616 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::ModelsRead));
1617 // Sending requests spends the workspace's AI credit: chosen on purpose.
1618 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1619 assert!(!preset.scopes().unwrap().contains(&Scope::ModelsWrite), "{}", preset.as_str());
1620 }
1621 assert!(Scope::ModelsWrite.includes(Scope::ModelsRead));
1622 assert!(!Scope::ModelsWrite.dangerous());
1623 assert!(token(&[Scope::ModelsWrite]).allows(Scope::ModelsWrite));
1624 assert!(!token(&[Scope::BillingWrite]).allows(Scope::ModelsWrite));
1625 assert!(TokenAccess::full().allows(Scope::ModelsWrite));
1626 }
1627
1628 #[test]
Merge main into Artifacts Phase 21629 fn artifacts_are_offered_read_by_presets_and_shared_only_with_admin() {
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 yet1630 for scope in [Scope::ArtifactsRead, Scope::ArtifactsWrite, Scope::ArtifactsAdmin] {
1631 assert_eq!(scope.resource(), Resource::Artifacts);
Merge main into Artifacts Phase 21632 assert!(scope.offered());
1633 assert!(offered_scopes().contains(&scope));
1634 assert_eq!(parse_scopes(scope.as_str()), vec![scope]);
1635 assert!(OPERATIONS.iter().any(|(_, needed)| *needed == scope), "{scope:?} gates nothing");
1636 }
1637 // Reading them is a read like any other; changing them is chosen.
1638 for preset in [Preset::ReadOnly, Preset::Agent] {
1639 let scopes = preset.scopes().unwrap();
1640 assert!(scopes.contains(&Scope::ArtifactsRead), "{}", preset.as_str());
1641 assert!(!scopes.contains(&Scope::ArtifactsWrite) && !scopes.contains(&Scope::ArtifactsAdmin), "{}", preset.as_str());
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 yet1642 }
Merge main into Artifacts Phase 21643 assert!(!Preset::Ci.scopes().unwrap().contains(&Scope::ArtifactsRead));
1644 assert!(everything().contains(&Scope::ArtifactsAdmin));
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 yet1645 assert!(Scope::ArtifactsAdmin.includes(Scope::ArtifactsWrite));
1646 assert!(Scope::ArtifactsAdmin.dangerous());
Merge main into Artifacts Phase 21647 assert!(!Scope::ArtifactsWrite.dangerous());
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 yet1648 assert_eq!(Resource::Artifacts.group(), ResourceGroup::Workspace);
Merge main into Artifacts Phase 21649 let asked = std::collections::BTreeMap::from([("artifacts".to_owned(), "write".to_owned())]);
1650 assert_eq!(resolve_permissions(&asked, true), Ok(vec![Scope::ArtifactsWrite]));
1651 assert_eq!(offered_scopes().len(), Scope::ALL.len());
1652 // Sharing and deleting for good need admin; editing needs write.
1653 assert_eq!(scope_for("set_workspace_artifact_access"), Some(Scope::ArtifactsAdmin));
1654 assert_eq!(scope_for("purge_workspace_artifact"), Some(Scope::ArtifactsAdmin));
1655 assert_eq!(scope_for("edit_workspace_artifact"), Some(Scope::ArtifactsWrite));
1656 assert_eq!(scope_for("get_workspace_artifact_content"), Some(Scope::ArtifactsRead));
1657 let writer = token(&[Scope::ArtifactsWrite]);
1658 assert!(decide(&writer, "edit_workspace_artifact", &json!({})).allowed);
1659 assert!(decide(&writer, "list_workspace_artifacts", &json!({})).allowed, "write includes read");
1660 assert!(decide(&writer, "set_workspace_artifact_access", &json!({})).reason.unwrap().contains("artifacts:admin"));
1661 // Workflow runs' artifacts are another thing, with their own scope.
1662 let reader = token(&[Scope::ArtifactsRead]);
1663 assert!(!decide(&reader, "list_artifacts", &json!({ "repo": "acme/web" })).allowed);
1664 assert!(!decide(&token(&[Scope::WorkflowsRead]), "list_workspace_artifacts", &json!({})).allowed);
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 yet1665 }
1666
1667 #[test]
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)1668 fn chat_is_a_persons_own_and_given_only_when_asked_for() {
1669 for scope in [Scope::ChatRead, Scope::ChatWrite] {
1670 assert_eq!(scope.resource(), Resource::Chat);
1671 assert!(!scope.dangerous());
1672 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
1673 }
1674 assert_eq!(Resource::Chat.label(), "Chat");
1675 assert_eq!(Resource::Chat.group(), ResourceGroup::Account);
1676 assert!(Scope::ChatWrite.includes(Scope::ChatRead));
1677 assert!(!Scope::ChatRead.includes(Scope::ChatWrite));
1678 // No preset, and so no OAuth default, gives it.
1679 for preset in Preset::ALL {
1680 let scopes = preset.scopes().unwrap_or_default();
1681 assert!(!scopes.iter().any(|scope| scope.resource() == Resource::Chat), "{}", preset.as_str());
1682 }
1683 assert!(!oauth_default().iter().any(|scope| scope.resource() == Resource::Chat));
1684 // An application that asks for it by name gets it; full access holds it.
1685 assert_eq!(parse_scopes("chat:read chat:write"), vec![Scope::ChatRead, Scope::ChatWrite]);
1686 assert!(offered_scopes().contains(&Scope::ChatWrite));
1687 assert!(everything().contains(&Scope::ChatWrite));
1688 assert!(TokenAccess::full().allows(Scope::ChatWrite));
1689 // Only a person's token may hold it.
1690 let asked = std::collections::BTreeMap::from([("chat".to_owned(), "write".to_owned())]);
1691 assert_eq!(resolve_permissions(&asked, true), Ok(vec![Scope::ChatWrite]));
1692 assert!(resolve_permissions(&asked, false).is_err());
1693 }
1694
1695 #[test]
Merge checks: statuses and check runs on every commit1696 fn checks_are_reported_with_checks_write_which_ci_gets() {
1697 assert_eq!(scope_for("create_check_run"), Some(Scope::ChecksWrite));
1698 assert_eq!(scope_for("create_commit_status"), Some(Scope::ChecksWrite));
1699 assert_eq!(scope_for("list_check_runs_for_ref"), Some(Scope::ChecksRead));
1700 let ci = Preset::Ci.scopes().unwrap();
1701 assert!(ci.contains(&Scope::ChecksWrite));
1702 assert!(!Preset::Agent.scopes().unwrap().contains(&Scope::ChecksWrite));
1703 let reporter = token(&[Scope::ChecksWrite]);
1704 assert!(decide(&reporter, "update_check_run", &json!({ "id": "cr_1" })).allowed);
1705 assert!(decide(&reporter, "rerequest_check_run", &json!({ "id": "cr_1" })).allowed);
1706 // A g1t Actions job runs again as its workflow does.
1707 let refused = decide(&reporter, "rerequest_check_run", &json!({ "id": "job_1" }));
1708 assert!(refused.reason.unwrap().contains("workflows:write"));
1709 }
1710
1711 #[test]
Merge branch 'worktree-agent-a3abfcce648e87dca'1712 fn a_job_token_reaches_its_repository_only() {
1713 let job = TokenAccess {
1714 repo: Some("acme/web".into()),
1715 job: Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false }),
1716 ..token(&[Scope::RepoRead, Scope::IssuesWrite, Scope::IssuesRead, Scope::PullRequestsWrite])
1717 };
1718 assert!(decide(&job, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1719 assert!(decide(&job, "create_issue", &json!({ "repo": "Acme/Web" })).allowed, "names compare without case");
1720 let elsewhere = decide(&job, "create_issue", &json!({ "repo": "acme/api" }));
1721 assert!(!elsewhere.allowed);
1722 assert_eq!(elsewhere.rule, "token:repository");
1723 // Nothing beyond the one repository, a workspace's listing included.
1724 assert!(!decide(&job, "list_repos", &json!({})).allowed);
1725 assert!(decide(&job, "whoami", &json!({})).allowed);
1726 // Its scopes still hold inside it.
1727 assert!(!decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1728 assert!(decide_repo(&job, "acme/web").is_none());
1729 assert!(!decide_repo(&job, "acme/api").unwrap().allowed);
1730 assert!(decide_repo(&token(&[Scope::CodeRead]), "acme/api").is_none(), "other tokens reach what their owner can");
1731 // Opening and approving pull requests is off unless allowed.
1732 assert_eq!(decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).rule, "token:pull-requests");
1733 assert!(!decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "approve" })).allowed);
1734 assert!(decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "request_changes" })).allowed);
1735 let allowed = TokenAccess { job: Some(JobToken { pull_requests: true, ..job.job.clone().unwrap() }), ..job.clone() };
1736 assert!(decide(&allowed, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1737 }
1738
1739 #[test]
Workflow files need workflow_files:write from a token; fine-grained permission table1740 fn workflow_files_need_their_own_scope() {
1741 for path in [".g1t/workflows/ci.yml", ".github/workflows/deploy.yaml", "/.github/workflows/x.yml", ".GitHub/Workflows/ci.yml", ".github/workflows"] {
1742 assert!(is_workflow_file(path), "{path}");
1743 }
1744 for path in ["README.md", ".github/CODEOWNERS", ".github/workflowsx/ci.yml", "docs/.github/workflows/ci.yml", ".g1t/actions/ci.yml"] {
1745 assert!(!is_workflow_file(path), "{path}");
1746 }
1747 let code = token(&[Scope::CodeWrite]);
1748 let refused = decide_workflow_files(Some(&code), ["README.md", ".github/workflows/ci.yml"]).unwrap();
1749 assert_eq!(refused.rule, "token:workflows");
1750 assert!(refused.reason.as_deref().unwrap().contains(".github/workflows/ci.yml"));
1751 assert!(refused.reason.as_deref().unwrap().contains("workflow_files:write"));
1752 assert!(decide_workflow_files(Some(&code), ["README.md"]).is_none());
1753 assert!(decide_workflow_files(Some(&token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite])), [".g1t/workflows/ci.yml"]).is_none());
1754 assert!(decide_workflow_files(Some(&TokenAccess::full()), [".g1t/workflows/ci.yml"]).is_none(), "full access");
1755 assert!(decide_workflow_files(None, [".g1t/workflows/ci.yml"]).is_none(), "a signed-in person");
1756 // A job's token never may, as GITHUB_TOKEN never may.
1757 let job = TokenAccess { job: Some(JobToken::default()), ..TokenAccess::full() };
1758 assert!(decide_workflow_files(Some(&job), [".g1t/workflows/ci.yml"]).unwrap().reason.unwrap().contains("job"));
1759 // Nothing in a preset changes workflow files but full access.
1760 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1761 assert!(!preset.scopes().unwrap().contains(&Scope::WorkflowFilesWrite), "{}", preset.as_str());
1762 }
1763 assert!(!Scope::WorkflowFilesWrite.includes(Scope::WorkflowsWrite) && !Scope::WorkflowsWrite.includes(Scope::WorkflowFilesWrite));
1764 }
1765
1766 #[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 managers1767 fn a_narrowed_token_only_reads_outside_its_workspace() {
1768 let reach = TokenReach { workspace: Some("acme".into()), repositories: RepositorySelection::All, repo_ids: Vec::new() };
1769 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 table1770 assert!(decide(&fine, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1771 assert!(decide(&fine, "create_issue", &json!({ "repo": "Acme/web" })).allowed);
1772 let elsewhere = decide(&fine, "create_issue", &json!({ "repo": "globex/site" }));
1773 assert_eq!(elsewhere.rule, "token:resource-owner");
1774 assert!(elsewhere.reason.unwrap().contains("acme"));
1775 assert!(decide(&fine, "get_issue", &json!({ "repo": "globex/site" })).allowed, "public repositories elsewhere read");
1776 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 managers1777 let mine = TokenAccess { reach: Some(TokenReach::default()), ..token(&[Scope::IssuesWrite]) };
Workflow files need workflow_files:write from a token; fine-grained permission table1778 assert!(decide(&mine, "create_issue", &json!({ "repo": "acme/web" })).reason.unwrap().contains("your account"));
1779 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 managers1780 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 table1781 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 managers1782 let public = TokenReach { repositories: RepositorySelection::Public, ..selected.clone() };
Workflow files need workflow_files:write from a token; fine-grained permission table1783 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 managers1784 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 table1785 assert_eq!(RepositorySelection::parse("public_only"), Some(RepositorySelection::Public));
1786 }
1787
1788 #[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 managers1789 fn permissions_are_scopes_read_per_resource() {
1790 let asked: std::collections::BTreeMap<String, String> =
1791 [("issues", "write"), ("repo", "read"), ("code", "none"), ("packages", "delete")].iter().map(|(a, b)| ((*a).to_owned(), (*b).to_owned())).collect();
1792 let scopes = resolve_permissions(&asked, true).unwrap();
1793 assert_eq!(scopes, vec![Scope::RepoRead, Scope::PackagesDelete, Scope::IssuesWrite]);
1794 let back = permissions_of(&scopes);
1795 assert_eq!(back.get("issues").map(String::as_str), Some("write"));
1796 assert_eq!(back.get("packages").map(String::as_str), Some("delete"));
1797 assert!(!back.contains_key("code"));
1798 // Lower levels held beside a higher one say nothing more.
1799 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 yet1800 // 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 managers1801 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 yet1802 assert_eq!(all.len(), Resource::ALL.into_iter().filter(|resource| resource.offered()).count());
1803 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 managers1804 assert!(all.iter().any(|held| held.includes(scope)), "{scope:?}");
1805 }
1806 }
1807
1808 #[test]
1809 fn permissions_are_checked_by_name_level_and_owner() {
1810 let one = |name: &str, level: &str| -> std::collections::BTreeMap<String, String> { [(name.to_owned(), level.to_owned())].into() };
1811 assert!(resolve_permissions(&one("wiki", "read"), true).unwrap_err().contains("wiki"));
1812 assert!(resolve_permissions(&one("issues", "admin"), true).unwrap_err().contains("read, write"));
1813 assert!(resolve_permissions(&one("workflow_files", "read"), true).is_err(), "workflow files are written only");
1814 assert!(resolve_permissions(&one("notifications", "read"), false).unwrap_err().contains("account"));
1815 assert_eq!(resolve_permissions(&one("notifications", "read"), true).unwrap(), vec![Scope::NotificationsRead]);
1816 assert_eq!(resolve_permissions(&one("agents", "run"), false).unwrap(), vec![Scope::AgentsRun]);
1817 for resource in Resource::ALL {
1818 assert_eq!(Resource::parse(resource.as_str()), Some(resource));
1819 assert!(!resource.scopes().is_empty());
1820 }
1821 }
1822
1823 #[test]
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01824 fn a_legacy_token_can_do_everything() {
1825 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1826 for (operation, _) in OPERATIONS {
1827 assert!(decide(&legacy, operation, &json!({})).allowed, "{operation}");
1828 }
1829 assert_eq!(decide(&legacy, "delete_repo", &json!({})).rule, "token:legacy");
1830 }
1831
1832 #[test]
1833 fn a_missing_scope_is_named() {
1834 let read = token(&[Scope::IssuesRead]);
1835 assert!(decide(&read, "get_issue", &json!({})).allowed);
1836 assert!(decide(&read, "whoami", &json!({})).allowed);
1837 let refused = decide(&read, "create_issue", &json!({}));
1838 assert!(!refused.allowed);
1839 assert_eq!(refused.reason.as_deref(), Some("This access token needs the issues:write scope to use create_issue."));
1840 // An operation the table does not know needs full access.
1841 assert!(!decide(&read, "something_new", &json!({})).allowed);
1842 }
1843
1844 #[test]
1845 fn starting_agents_from_another_operation_needs_agents_run() {
1846 let writer = token(&[Scope::IssuesWrite]);
1847 assert!(decide(&writer, "apply_plan", &json!({})).allowed);
1848 let refused = decide(&writer, "apply_plan", &json!({ "assign": true }));
1849 assert!(refused.reason.unwrap().contains("agents:run"));
1850 let maintainer = token(&[Scope::RepoWrite]);
1851 assert!(decide(&maintainer, "update_repo", &json!({ "description": "x" })).allowed);
1852 assert!(!decide(&maintainer, "update_repo", &json!({ "private": true })).allowed);
1853 }
1854
1855 #[test]
1856 fn a_workspaces_base_permission_needs_access_admin_too() {
1857 let admin = token(&[Scope::WorkspaceAdmin]);
1858 assert!(decide(&admin, "update_workspace", &json!({ "name": "Acme" })).allowed);
1859 let refused = decide(&admin, "update_workspace", &json!({ "name": "Acme", "base_permission": "read" }));
1860 assert!(refused.reason.unwrap().contains("access:admin"));
1861 let both = token(&[Scope::WorkspaceAdmin, Scope::AccessAdmin]);
1862 assert!(decide(&both, "update_workspace", &json!({ "base_permission": "read" })).allowed);
1863 assert!(!decide(&token(&[Scope::WorkspaceRead]), "update_workspace", &json!({ "name": "Acme" })).allowed);
1864 }
1865
1866 #[test]
1867 fn delegating_needs_both_agents_and_issues() {
1868 let agents = token(&[Scope::AgentsRun]);
1869 assert!(decide(&agents, "delegate", &json!({})).reason.unwrap().contains("issues:write"));
1870 let both = token(&[Scope::AgentsRun, Scope::IssuesWrite]);
1871 assert!(decide(&both, "delegate", &json!({})).allowed);
1872 }
1873
1874 #[test]
1875 fn git_push_needs_code_write_and_private_reads_need_code_read() {
1876 let reader = token(&[Scope::CodeRead]);
1877 assert!(decide_git(&reader, false, false).allowed);
1878 let refused = decide_git(&reader, true, false);
1879 assert!(!refused.allowed);
1880 assert!(refused.reason.unwrap().contains("code:write"));
1881 let issues = token(&[Scope::IssuesWrite]);
1882 assert!(!decide_git(&issues, false, false).allowed);
1883 assert!(decide_git(&issues, false, true).allowed, "public code needs no scope");
1884 assert!(!decide_git(&issues, true, true).allowed, "pushing to public code still needs code:write");
1885 let writer = token(&[Scope::CodeWrite]);
1886 assert!(decide_git(&writer, true, false).allowed);
1887 assert!(decide_git(&writer, false, false).allowed, "code:write includes code:read");
1888 assert!(decide_git(&TokenAccess::full(), true, false).allowed);
1889 }
1890
1891 #[test]
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1892 fn packages_need_their_own_scopes_and_public_pulls_none() {
1893 let reader = token(&[Scope::PackagesRead]);
1894 assert!(decide_packages(&reader, Level::Read, false).allowed);
1895 assert!(!decide_packages(&reader, Level::Write, false).allowed);
1896 let code = token(&[Scope::CodeWrite]);
1897 assert!(!decide_packages(&code, Level::Read, false).allowed, "code scopes are not package scopes");
1898 assert!(decide_packages(&code, Level::Read, true).allowed, "public packages pull with any token");
1899 let writer = token(&[Scope::PackagesWrite]);
1900 assert!(decide_packages(&writer, Level::Write, false).allowed);
1901 assert!(decide_packages(&writer, Level::Read, false).allowed, "packages:write includes packages:read");
1902 let refused = decide_packages(&writer, Level::Delete, false);
1903 assert!(refused.reason.unwrap().contains("packages:delete"));
1904 assert!(decide_packages(&token(&[Scope::PackagesDelete]), Level::Write, false).allowed);
1905 assert!(Scope::PackagesDelete.dangerous());
1906 // Tokens made before these scopes, and full-access ones, keep working.
1907 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1908 assert!(decide_packages(&legacy, Level::Delete, false).allowed);
1909 assert!(decide_packages(&TokenAccess::full(), Level::Write, false).allowed);
1910 }
1911
1912 #[test]
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01913 fn token_access_travels_as_json() {
1914 let access = token(&[Scope::IssuesRead]);
1915 let wire = serde_json::to_value(&access).unwrap();
1916 assert_eq!(wire["scopes"], json!(["issues:read"]));
1917 assert!(wire.get("resources").is_none());
1918 let back: TokenAccess = serde_json::from_value(wire).unwrap();
1919 assert_eq!(back, access);
1920 let full: TokenAccess = serde_json::from_value(json!({})).unwrap();
1921 assert!(full.is_full());
1922 // A reach written by an older version is ignored: a token reaches
1923 // whatever its owner can.
1924 let older: TokenAccess = serde_json::from_value(json!({
1925 "token_id": "tok_1",
1926 "scopes": ["issues:read"],
1927 "resources": { "kind": "repositories", "repositories": ["acme/rocket"] },
1928 }))
1929 .unwrap();
1930 assert_eq!(older, access);
Merge main into Artifacts Phase 21931 // Using the website is off unless set, and said only when on.
1932 assert!(!access.website);
1933 assert!(wire_of(&access).get("website").is_none());
1934 let website = TokenAccess { website: true, ..access };
1935 assert_eq!(wire_of(&website)["website"], json!(true));
1936 // Never part of full access.
1937 assert!(!TokenAccess::full().website);
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01938 }
1939
Merge main into Artifacts Phase 21940 fn wire_of(access: &TokenAccess) -> serde_json::Value {
1941 serde_json::to_value(access).unwrap()
1942 }
1943
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01944 /// The site's copy of the table, `packages/contracts/src/scopes.ts`,
1945 /// lists the same scopes in the same order, the same operations with
1946 /// the same scopes, and the same presets.
1947 #[test]
1948 fn the_typescript_mirror_has_the_same_table() {
1949 let ts = include_str!("../../../packages/contracts/src/scopes.ts");
1950 let section = |start: &str| {
1951 ts.split_once(start)
1952 .and_then(|(_, rest)| rest.split_once("] as const"))
1953 .map(|(table, _)| table)
1954 .unwrap_or_else(|| panic!("{start} in scopes.ts"))
1955 };
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 yet1956 let names = |table: &str| -> Vec<String> {
1957 section(table)
1958 .lines()
1959 .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope.to_owned()))
1960 .collect()
1961 };
1962 // Offered scopes in `SCOPES`, the rest in `UPCOMING_SCOPES`.
1963 let offered: Vec<String> = Scope::ALL.iter().filter(|scope| scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
1964 let upcoming: Vec<String> = Scope::ALL.iter().filter(|scope| !scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
1965 assert_eq!(names("export const SCOPES = ["), offered);
Merge main into Artifacts Phase 21966 assert_eq!(names("export const UPCOMING_SCOPES"), upcoming);
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.01967 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
1968 .lines()
1969 .filter_map(|line| {
1970 let mut quoted = line.split('"').skip(1).step_by(2);
1971 Some((quoted.next()?.to_owned(), quoted.next()?.to_owned()))
1972 })
1973 .collect();
1974 let expected: Vec<(String, String)> = OPERATIONS
1975 .iter()
1976 .map(|(name, scope)| ((*name).to_owned(), scope.as_str().to_owned()))
1977 .collect();
1978 assert_eq!(operations, expected);
1979 for preset in Preset::ALL {
1980 let list = section(&format!("{}: [", preset.as_str()));
1981 let mirrored: Vec<&str> = list
1982 .split(',')
1983 .map(|item| item.trim().trim_matches('"'))
1984 .filter(|item| !item.is_empty())
1985 .collect();
1986 let expected: Vec<&str> = preset
1987 .scopes()
1988 .map(|scopes| scopes.iter().map(|scope| scope.as_str()).collect())
1989 .unwrap_or_else(|| vec!["*"]);
1990 assert_eq!(mirrored, expected, "{}", preset.as_str());
1991 }
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 yet1992 // Each resource with its group, in the same order: offered ones in
1993 // `SCOPE_RESOURCES`, the rest in `UPCOMING_RESOURCES`.
1994 for (table, offered) in [("export const SCOPE_RESOURCES", true), ("export const UPCOMING_RESOURCES", false)] {
1995 let resources = ts
1996 .split_once(table)
1997 .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 managers1998];"))
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 yet1999 .map(|(table, _)| table)
2000 .unwrap_or_else(|| panic!("{table} in scopes.ts"));
2001 let rows: Vec<&str> = resources.lines().filter(|line| line.trim_start().starts_with("{ resource:")).collect();
2002 let expected: Vec<Resource> = Resource::ALL.into_iter().filter(|resource| resource.offered() == offered).collect();
2003 assert_eq!(rows.len(), expected.len(), "{table}");
2004 for (row, resource) in rows.iter().zip(expected) {
2005 assert!(row.contains(&format!("resource: \"{}\"", resource.as_str())), "{row}");
2006 assert!(row.contains(&format!("group: \"{}\"", resource.group().as_str())), "{row}");
2007 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers2008 }
Cleanup: clippy is quiet outside billing, the README says g1t, and http-cache-semantics is 4.3.02009 }
2010}

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