Skip to content
1,973 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.

Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1//! 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,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow31 Workspace,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit32 Billing,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow33 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,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow37 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,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow44 Memory,
45 Access,
46 Webhooks,
47 Secrets,
Fast pages, required checks on the branch, self-hosted runners, honest incidents48 Runners,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens49 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 yet50 /// Artifacts mode's docs, slides, designs and dashboards (folios in
Merge main into Artifacts Phase 251 /// code): the `artifact` MCP tool and `/workspaces/{ws}/artifacts`.
52 /// 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 yet53 Artifacts,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow54}
55
56impl Resource {
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)57 pub const ALL: [Resource; 23] = [
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow58 Resource::Repo,
59 Resource::Code,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar60 Resource::Security,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member61 Resource::Packages,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow62 Resource::Issues,
63 Resource::PullRequests,
64 Resource::Agents,
65 Resource::Workflows,
Token reach: workflow_files scope, fine-grained reach, workspace token cap66 Resource::WorkflowFiles,
Merge checks: statuses and check runs on every commit67 Resource::Checks,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9768 Resource::Deployments,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow69 Resource::Memory,
70 Resource::Account,
API: notifications over REST and MCP, with notifications scopes71 Resource::Notifications,
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)72 Resource::Chat,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow73 Resource::Workspace,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit74 Resource::Billing,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow75 Resource::Access,
76 Resource::Webhooks,
77 Resource::Secrets,
Fast pages, required checks on the branch, self-hosted runners, honest incidents78 Resource::Runners,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens79 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 yet80 Resource::Artifacts,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow81 ];
82
83 pub fn as_str(self) -> &'static str {
84 match self {
85 Resource::Account => "account",
API: notifications over REST and MCP, with notifications scopes86 Resource::Notifications => "notifications",
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)87 Resource::Chat => "chat",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow88 Resource::Workspace => "workspace",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit89 Resource::Billing => "billing",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow90 Resource::Repo => "repo",
91 Resource::Code => "code",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar92 Resource::Security => "security",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member93 Resource::Packages => "packages",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow94 Resource::Issues => "issues",
95 Resource::PullRequests => "pull_requests",
96 Resource::Agents => "agents",
97 Resource::Workflows => "workflows",
Token reach: workflow_files scope, fine-grained reach, workspace token cap98 Resource::WorkflowFiles => "workflow_files",
Merge checks: statuses and check runs on every commit99 Resource::Checks => "checks",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97100 Resource::Deployments => "deployments",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow101 Resource::Memory => "memory",
102 Resource::Access => "access",
103 Resource::Webhooks => "webhooks",
104 Resource::Secrets => "secrets",
Fast pages, required checks on the branch, self-hosted runners, honest incidents105 Resource::Runners => "runners",
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens106 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 yet107 Resource::Artifacts => "artifacts",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow108 }
109 }
110
111 /// Its name, for people.
112 pub fn label(self) -> &'static str {
113 match self {
114 Resource::Account => "Your account",
API: notifications over REST and MCP, with notifications scopes115 Resource::Notifications => "Notifications",
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)116 Resource::Chat => "Chat",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow117 Resource::Workspace => "Workspaces",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit118 Resource::Billing => "Billing",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow119 Resource::Repo => "Repositories",
120 Resource::Code => "Code",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar121 Resource::Security => "Security",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member122 Resource::Packages => "Packages",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow123 Resource::Issues => "Issues",
124 Resource::PullRequests => "Pull requests",
125 Resource::Agents => "g1t agents",
126 Resource::Workflows => "Workflows",
Token reach: workflow_files scope, fine-grained reach, workspace token cap127 Resource::WorkflowFiles => "Workflow files",
Merge checks: statuses and check runs on every commit128 Resource::Checks => "Checks and statuses",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97129 Resource::Deployments => "Deployments",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow130 Resource::Memory => "Memory and context",
131 Resource::Access => "Who has access",
132 Resource::Webhooks => "Webhooks",
133 Resource::Secrets => "Secrets and variables",
Fast pages, required checks on the branch, self-hosted runners, honest incidents134 Resource::Runners => "Self-hosted runners",
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens135 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 yet136 Resource::Artifacts => "Artifacts",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow137 }
138 }
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 yet139
Merge main into Artifacts Phase 2140 /// Whether tokens are offered it yet. A resource being built can be in
141 /// the table before its API ships (so its scopes parse, and the
142 /// TypeScript mirror lists it under `UPCOMING_RESOURCES`) while nothing
143 /// hands it out: presets, full access, OAuth and the token form leave
144 /// 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.145 /// 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 yet146 pub fn offered(self) -> bool {
Merge main into Artifacts Phase 2147 let _ = self;
148 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 yet149 }
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow150}
151
152/// How much of a resource.
153#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
154pub enum Level {
155 Read,
156 Write,
157 /// Starting g1t's agents, which spends the workspace's money.
158 Run,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member159 /// Deleting what cannot be brought back, such as a package's versions.
160 Delete,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow161 Admin,
162}
163
164impl Level {
165 pub fn as_str(self) -> &'static str {
166 match self {
167 Level::Read => "read",
168 Level::Write => "write",
169 Level::Run => "run",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member170 Level::Delete => "delete",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow171 Level::Admin => "admin",
172 }
173 }
174}
175
176/// One scope. Its text form, `resource:level`, is what tokens store, OAuth
177/// clients ask for, and errors name.
178#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
179pub enum Scope {
180 RepoRead,
181 RepoWrite,
182 RepoAdmin,
183 CodeRead,
184 CodeWrite,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar185 SecurityRead,
186 SecurityWrite,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member187 PackagesRead,
188 PackagesWrite,
189 PackagesDelete,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow190 IssuesRead,
191 IssuesWrite,
192 PullRequestsRead,
193 PullRequestsWrite,
194 AgentsRun,
195 WorkflowsRead,
196 WorkflowsWrite,
Token reach: workflow_files scope, fine-grained reach, workspace token cap197 WorkflowFilesWrite,
Merge checks: statuses and check runs on every commit198 ChecksRead,
199 ChecksWrite,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97200 DeploymentsRead,
201 DeploymentsWrite,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow202 MemoryRead,
203 MemoryWrite,
204 AccountRead,
205 AccountWrite,
API: notifications over REST and MCP, with notifications scopes206 NotificationsRead,
207 NotificationsWrite,
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)208 ChatRead,
209 ChatWrite,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow210 WorkspaceRead,
211 WorkspaceAdmin,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit212 BillingRead,
213 BillingWrite,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow214 AccessRead,
215 AccessAdmin,
216 WebhooksRead,
217 WebhooksAdmin,
218 SecretsRead,
219 SecretsAdmin,
Fast pages, required checks on the branch, self-hosted runners, honest incidents220 RunnersRead,
221 RunnersAdmin,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens222 ModelsRead,
223 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 yet224 ArtifactsRead,
225 ArtifactsWrite,
226 ArtifactsAdmin,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow227}
228
229impl Scope {
230 /// Every scope, grouped by resource, least first.
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)231 pub const ALL: [Scope; 47] = [
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow232 Scope::RepoRead,
233 Scope::RepoWrite,
234 Scope::RepoAdmin,
235 Scope::CodeRead,
236 Scope::CodeWrite,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar237 Scope::SecurityRead,
238 Scope::SecurityWrite,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member239 Scope::PackagesRead,
240 Scope::PackagesWrite,
241 Scope::PackagesDelete,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow242 Scope::IssuesRead,
243 Scope::IssuesWrite,
244 Scope::PullRequestsRead,
245 Scope::PullRequestsWrite,
246 Scope::AgentsRun,
247 Scope::WorkflowsRead,
248 Scope::WorkflowsWrite,
Token reach: workflow_files scope, fine-grained reach, workspace token cap249 Scope::WorkflowFilesWrite,
Merge checks: statuses and check runs on every commit250 Scope::ChecksRead,
251 Scope::ChecksWrite,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97252 Scope::DeploymentsRead,
253 Scope::DeploymentsWrite,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow254 Scope::MemoryRead,
255 Scope::MemoryWrite,
256 Scope::AccountRead,
257 Scope::AccountWrite,
API: notifications over REST and MCP, with notifications scopes258 Scope::NotificationsRead,
259 Scope::NotificationsWrite,
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)260 Scope::ChatRead,
261 Scope::ChatWrite,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow262 Scope::WorkspaceRead,
263 Scope::WorkspaceAdmin,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit264 Scope::BillingRead,
265 Scope::BillingWrite,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow266 Scope::AccessRead,
267 Scope::AccessAdmin,
268 Scope::WebhooksRead,
269 Scope::WebhooksAdmin,
270 Scope::SecretsRead,
271 Scope::SecretsAdmin,
Fast pages, required checks on the branch, self-hosted runners, honest incidents272 Scope::RunnersRead,
273 Scope::RunnersAdmin,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens274 Scope::ModelsRead,
275 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 yet276 Scope::ArtifactsRead,
277 Scope::ArtifactsWrite,
278 Scope::ArtifactsAdmin,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow279 ];
280
281 pub fn as_str(self) -> &'static str {
282 match self {
283 Scope::RepoRead => "repo:read",
284 Scope::RepoWrite => "repo:write",
285 Scope::RepoAdmin => "repo:admin",
286 Scope::CodeRead => "code:read",
287 Scope::CodeWrite => "code:write",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar288 Scope::SecurityRead => "security:read",
289 Scope::SecurityWrite => "security:write",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member290 Scope::PackagesRead => "packages:read",
291 Scope::PackagesWrite => "packages:write",
292 Scope::PackagesDelete => "packages:delete",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow293 Scope::IssuesRead => "issues:read",
294 Scope::IssuesWrite => "issues:write",
295 Scope::PullRequestsRead => "pull_requests:read",
296 Scope::PullRequestsWrite => "pull_requests:write",
297 Scope::AgentsRun => "agents:run",
298 Scope::WorkflowsRead => "workflows:read",
299 Scope::WorkflowsWrite => "workflows:write",
Token reach: workflow_files scope, fine-grained reach, workspace token cap300 Scope::WorkflowFilesWrite => "workflow_files:write",
Merge checks: statuses and check runs on every commit301 Scope::ChecksRead => "checks:read",
302 Scope::ChecksWrite => "checks:write",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97303 Scope::DeploymentsRead => "deployments:read",
304 Scope::DeploymentsWrite => "deployments:write",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow305 Scope::MemoryRead => "memory:read",
306 Scope::MemoryWrite => "memory:write",
307 Scope::AccountRead => "account:read",
308 Scope::AccountWrite => "account:write",
API: notifications over REST and MCP, with notifications scopes309 Scope::NotificationsRead => "notifications:read",
310 Scope::NotificationsWrite => "notifications:write",
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)311 Scope::ChatRead => "chat:read",
312 Scope::ChatWrite => "chat:write",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow313 Scope::WorkspaceRead => "workspace:read",
314 Scope::WorkspaceAdmin => "workspace:admin",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit315 Scope::BillingRead => "billing:read",
316 Scope::BillingWrite => "billing:write",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow317 Scope::AccessRead => "access:read",
318 Scope::AccessAdmin => "access:admin",
319 Scope::WebhooksRead => "webhooks:read",
320 Scope::WebhooksAdmin => "webhooks:admin",
321 Scope::SecretsRead => "secrets:read",
322 Scope::SecretsAdmin => "secrets:admin",
Fast pages, required checks on the branch, self-hosted runners, honest incidents323 Scope::RunnersRead => "runners:read",
324 Scope::RunnersAdmin => "runners:admin",
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens325 Scope::ModelsRead => "models:read",
326 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 yet327 Scope::ArtifactsRead => "artifacts:read",
328 Scope::ArtifactsWrite => "artifacts:write",
329 Scope::ArtifactsAdmin => "artifacts:admin",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow330 }
331 }
332
333 pub fn parse(text: &str) -> Option<Scope> {
334 let text = text.trim().to_ascii_lowercase();
335 Scope::ALL.into_iter().find(|scope| scope.as_str() == text)
336 }
337
338 pub fn resource(self) -> Resource {
339 let name = self.as_str().split_once(':').map_or("", |(resource, _)| resource);
340 Resource::ALL
341 .into_iter()
342 .find(|resource| resource.as_str() == name)
343 .unwrap_or(Resource::Account)
344 }
345
346 pub fn level(self) -> Level {
347 match self.as_str().rsplit_once(':').map_or("", |(_, level)| level) {
348 "write" => Level::Write,
349 "run" => Level::Run,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member350 "delete" => Level::Delete,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow351 "admin" => Level::Admin,
352 _ => Level::Read,
353 }
354 }
355
356 /// Whether holding `self` gives `other`: the same resource, at the same
357 /// level or a lower one.
358 pub fn includes(self, other: Scope) -> bool {
359 self.resource() == other.resource() && self.level() >= other.level()
360 }
361
362 /// Changes that are hard or impossible to undo, or that decide who can
363 /// reach what. Shown behind a warning wherever scopes are chosen.
364 pub fn dangerous(self) -> bool {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member365 matches!(self.level(), Level::Admin | Level::Delete)
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow366 }
367
368 /// What it lets a token do, in plain words.
369 pub fn describe(self) -> &'static str {
370 match self {
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97371 Scope::RepoRead => "See repositories, their settings, labels, timelines, releases, languages, contributors and security alerts, and search",
372 Scope::RepoWrite => "Create repositories, rename branches, change how pull requests merge and publish releases",
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge373 Scope::RepoAdmin => "Rename, archive, transfer, delete or change who can see a repository, change its rulesets, and dismiss security alerts",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow374 Scope::CodeRead => "Clone and fetch private repositories with git",
375 Scope::CodeWrite => "Push commits with git",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar376 Scope::SecurityRead => "See secret scanning, code scanning and vulnerability alerts, custom patterns, the dependency graph and SBOM, and security settings",
377 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 member378 Scope::PackagesRead => "Pull container images and install private packages",
379 Scope::PackagesWrite => "Push container images and publish packages",
Merge packages: roles, Actions access, source label, soft delete, API380 Scope::PackagesDelete => "Delete and restore packages and their versions",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow381 Scope::IssuesRead => "Read issues, comments and plans",
382 Scope::IssuesWrite => "Open, edit, close and comment on issues",
383 Scope::PullRequestsRead => "Read pull requests, their changes, sessions and merge queues",
384 Scope::PullRequestsWrite => "Open, review, close and merge pull requests",
385 Scope::AgentsRun => "Put g1t agents to work and message them, which uses the workspace's money",
386 Scope::WorkflowsRead => "Read workflows, runs and logs",
387 Scope::WorkflowsWrite => "Run, cancel, rerun and turn workflows on or off",
Token reach: workflow_files scope, fine-grained reach, workspace token cap388 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 commit389 Scope::ChecksRead => "Read commits' statuses, check runs, check suites and annotations",
390 Scope::ChecksWrite => "Report statuses and check runs on commits, and ask for checks to run again",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97391 Scope::DeploymentsRead => "See deployments, their statuses and environments",
392 Scope::DeploymentsWrite => "Report deployments and their statuses, from any CI",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow393 Scope::MemoryRead => "Recall memory and search the workspace's context",
394 Scope::MemoryWrite => "Save memory for the next agent",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97395 Scope::AccountRead => "Read your email addresses, invites, invitations, pinned projects and stars",
396 Scope::AccountWrite => "Change your email addresses, make invites, answer invitations, pin projects and star repositories",
API: notifications over REST and MCP, with notifications scopes397 Scope::NotificationsRead => "See your inbox, its threads, and what you subscribe to and watch",
398 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)399 Scope::ChatRead => "Read the conversations you are in: channels, direct messages, threads and reactions, live",
400 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 merge401 Scope::WorkspaceRead => "Read workspace settings, invites, integrations, model routes, teams and rulesets",
402 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 audit403 Scope::BillingRead => "See a workspace's usage, budget, AI credit and invoices",
404 Scope::BillingWrite => "Change a workspace's budget and buy AI credit",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow405 Scope::AccessRead => "See who has access to repositories",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar406 Scope::AccessAdmin => "Give and take away access to repositories, a team's included",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow407 Scope::WebhooksRead => "See webhooks and their deliveries",
408 Scope::WebhooksAdmin => "Create, change and delete webhooks",
409 Scope::SecretsRead => "List secrets (never their values) and read variables",
410 Scope::SecretsAdmin => "Set and delete secrets and variables",
Fast pages, required checks on the branch, self-hosted runners, honest incidents411 Scope::RunnersRead => "See self-hosted runners, their groups and where agents run",
412 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 tokens413 Scope::ModelsRead => "See the workspace's AI Gateway requests: their models, tokens, cost and status",
414 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 yet415 Scope::ArtifactsRead => "List, read and search artifacts you can see, their versions, and the numbers their dashboards show",
416 Scope::ArtifactsWrite => "Create, rename, move, edit, trash and restore artifacts, and propose changes to them",
417 Scope::ArtifactsAdmin => "Share artifacts, change who can open them, and delete them for good",
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow418 }
419 }
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 yet420
421 /// Whether tokens are offered it yet: its resource's [`Resource::offered`].
422 pub fn offered(self) -> bool {
423 self.resource().offered()
424 }
425}
426
427/// Every scope tokens are offered, in table order: what OAuth advertises.
428pub fn offered_scopes() -> Vec<Scope> {
429 Scope::ALL.into_iter().filter(|scope| scope.offered()).collect()
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow430}
431
432impl Serialize for Scope {
433 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
434 serializer.serialize_str(self.as_str())
435 }
436}
437
438impl<'de> Deserialize<'de> for Scope {
439 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
440 let text = String::deserialize(deserializer)?;
441 Scope::parse(&text).ok_or_else(|| serde::de::Error::custom(format!("unknown scope {text}")))
442 }
443}
444
445/// Scopes as written in a token's row or an OAuth request: separated by
446/// spaces or commas. Unknown names are left out, so a client asking for a
447/// scope from a newer version gets the rest.
448pub fn parse_scopes(text: &str) -> Vec<Scope> {
449 let mut scopes: Vec<Scope> = text
450 .split(|c: char| c.is_whitespace() || c == ',')
451 .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 yet452 .filter(|scope| scope.offered())
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow453 .collect();
454 normalize(&mut scopes);
455 scopes
456}
457
458/// In table order, without repeats.
459pub fn normalize(scopes: &mut Vec<Scope>) {
460 let given = std::mem::take(scopes);
461 scopes.extend(Scope::ALL.into_iter().filter(|scope| given.contains(scope)));
462}
463
464/// Space-separated, as stored and as OAuth writes them.
465pub fn scopes_text(scopes: &[Scope]) -> String {
466 scopes.iter().map(|scope| scope.as_str()).collect::<Vec<_>>().join(" ")
467}
468
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers469/// Where a resource sits on the token form, and which tokens may hold it.
470#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
471#[serde(rename_all = "snake_case")]
472pub enum ResourceGroup {
473 /// About repositories: what they hold and how they are run.
474 Repository,
475 /// About a workspace itself.
476 Workspace,
477 /// About the person: only a personal token may hold these.
478 Account,
479}
480
481impl ResourceGroup {
482 pub const ALL: [ResourceGroup; 3] = [ResourceGroup::Repository, ResourceGroup::Workspace, ResourceGroup::Account];
483
484 pub fn as_str(self) -> &'static str {
485 match self {
486 ResourceGroup::Repository => "repository",
487 ResourceGroup::Workspace => "workspace",
488 ResourceGroup::Account => "account",
489 }
490 }
491}
492
493impl Resource {
494 pub fn group(self) -> ResourceGroup {
495 match self {
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)496 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 yet497 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 managers498 _ => ResourceGroup::Repository,
499 }
500 }
501
502 pub fn parse(text: &str) -> Option<Resource> {
503 let text = text.trim().to_ascii_lowercase();
504 Resource::ALL.into_iter().find(|resource| resource.as_str() == text)
505 }
506
507 /// Its scopes, least first.
508 pub fn scopes(self) -> Vec<Scope> {
509 Scope::ALL.into_iter().filter(|scope| scope.resource() == self).collect()
510 }
511}
512
513// --- Permissions --------------------------------------------------------------
514//
515// A token's permissions are its scopes read per resource: each resource
516// at none or one level (`{"issues": "write", "repo": "read"}`). A level
517// includes the ones below it, so the highest scope held of each resource
518// says everything; that is what a token stores. Personal tokens and a
519// workspace's own tokens are made, shown and checked this way alike.
520
521/// The highest scope of each resource held, in table order: the fewest
522/// scopes that give the same access, as tokens store them.
523pub fn top_scopes(scopes: &[Scope]) -> Vec<Scope> {
524 let mut top: Vec<Scope> = Vec::new();
525 for resource in Resource::ALL {
526 if let Some(best) = scopes.iter().filter(|scope| scope.resource() == resource).max_by_key(|scope| scope.level()) {
527 top.push(*best);
528 }
529 }
530 normalize(&mut top);
531 top
532}
533
534/// Every resource at its highest level: all a token can be given.
535pub 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 yet536 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 managers537}
538
539/// Scopes as permissions: each resource held, by name, at its highest
540/// level held.
541pub fn permissions_of(scopes: &[Scope]) -> std::collections::BTreeMap<String, String> {
542 top_scopes(scopes)
543 .into_iter()
544 .map(|scope| (scope.resource().as_str().to_owned(), scope.level().as_str().to_owned()))
545 .collect()
546}
547
548/// Permissions as asked for (`{"issues": "write"}`, `none` or empty left
549/// out) into the scopes a token stores, or why they cannot be. `personal`
550/// is whether the token is a person's: only theirs may hold account ones.
551pub fn resolve_permissions(asked: &std::collections::BTreeMap<String, String>, personal: bool) -> Result<Vec<Scope>, String> {
552 let mut scopes = Vec::new();
553 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 yet554 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 managers555 return Err(format!("There is no permission called {name}."));
556 };
557 let level = level.trim().to_ascii_lowercase();
558 if level.is_empty() || level == "none" {
559 continue;
560 }
561 let Some(scope) = Scope::parse(&format!("{}:{level}", resource.as_str())) else {
562 let levels: Vec<&str> = resource.scopes().iter().map(|scope| scope.level().as_str()).collect();
563 return Err(format!("{} is none or {}, not {level}.", resource.as_str(), levels.join(", ")));
564 };
565 if resource.group() == ResourceGroup::Account && !personal {
566 return Err(format!("{} is about a person's account: a workspace's token cannot hold it.", resource.as_str()));
567 }
568 scopes.push(scope);
569 }
570 Ok(top_scopes(&scopes))
571}
572
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow573/// What a token stores for full access, which is not a scope a client can
574/// ask for by name.
575pub const FULL_ACCESS: &str = "*";
576
577/// Starting points for choosing scopes.
578#[derive(Clone, Copy, Debug, PartialEq, Eq)]
579pub enum Preset {
580 ReadOnly,
581 Agent,
582 Ci,
583 Full,
584}
585
586impl Preset {
587 pub const ALL: [Preset; 4] = [Preset::ReadOnly, Preset::Agent, Preset::Ci, Preset::Full];
588
589 pub fn as_str(self) -> &'static str {
590 match self {
591 Preset::ReadOnly => "read_only",
592 Preset::Agent => "agent",
593 Preset::Ci => "ci",
594 Preset::Full => "full",
595 }
596 }
597
598 pub fn label(self) -> &'static str {
599 match self {
600 Preset::ReadOnly => "Read only",
601 Preset::Agent => "Agent",
602 Preset::Ci => "CI",
603 Preset::Full => "Full access",
604 }
605 }
606
607 /// Its scopes; `None` for full access.
608 pub fn scopes(self) -> Option<Vec<Scope>> {
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)609 // Chat is not in any: a person's conversations go to an application
610 // only when it asks for them by name.
611 let reads = || {
612 Scope::ALL
613 .into_iter()
614 .filter(|scope| scope.level() == Level::Read && scope.offered() && scope.resource() != Resource::Chat)
615 };
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow616 match self {
617 Preset::ReadOnly => Some(reads().collect()),
618 Preset::Agent => {
Fast pages, required checks on the branch, self-hosted runners, honest incidents619 // Not the machines work runs on: an agent has no business
620 // knowing a workspace's own runners.
621 let mut scopes: Vec<Scope> = reads().filter(|scope| scope.resource() != Resource::Runners).collect();
API: notifications over REST and MCP, with notifications scopes622 // And answering what needs the person it works for: marking
623 // it done, subscribing, watching.
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow624 scopes.extend([
625 Scope::CodeWrite,
626 Scope::IssuesWrite,
627 Scope::PullRequestsWrite,
628 Scope::AgentsRun,
629 Scope::MemoryWrite,
API: notifications over REST and MCP, with notifications scopes630 Scope::NotificationsWrite,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow631 ]);
632 normalize(&mut scopes);
633 Some(scopes)
634 }
635 Preset::Ci => Some(vec![
636 Scope::RepoRead,
637 Scope::CodeRead,
638 Scope::CodeWrite,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member639 Scope::PackagesRead,
640 Scope::PackagesWrite,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow641 Scope::WorkflowsRead,
642 Scope::WorkflowsWrite,
Merge checks: statuses and check runs on every commit643 Scope::ChecksRead,
644 Scope::ChecksWrite,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97645 Scope::DeploymentsRead,
646 Scope::DeploymentsWrite,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow647 ]),
648 Preset::Full => None,
649 }
650 }
651}
652
653/// What an OAuth client gets when it asks for nothing in particular: the
654/// agent preset. Never an admin scope.
655pub fn oauth_default() -> Vec<Scope> {
656 Preset::Agent.scopes().unwrap_or_default()
657}
658
659/// Set on a [`crate::User`] resolved from an access token: what the token
660/// may do. Absent on a signed-in session, which may do whatever its person
661/// can.
662#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
663pub struct TokenAccess {
664 /// The token's id, as audit entries and errors name it.
665 #[serde(default)]
666 pub token_id: String,
667 /// Its scopes, as `resource:level`. Absent: full access, everything the
668 /// person (or workspace) can do.
669 #[serde(default, skip_serializing_if = "Option::is_none")]
670 pub scopes: Option<Vec<String>>,
671 /// Made before tokens had scopes: full access until someone narrows it.
672 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
673 pub legacy: bool,
Merge branch 'worktree-agent-a3abfcce648e87dca'674 /// Set on a workflow job's token (`G1T_TOKEN`): the one repository it
675 /// reaches, as `owner/name`. Every other is refused, whatever its owner
676 /// could reach.
677 #[serde(default, skip_serializing_if = "Option::is_none")]
678 pub repo: Option<String>,
679 /// Set on a workflow job's token: the run and job it was made for. The
680 /// audit log records its changes as that job's, and what it changes
681 /// starts no workflows (only `workflow_dispatch` and
682 /// `repository_dispatch` do), so a workflow cannot set itself off.
683 #[serde(default, skip_serializing_if = "Option::is_none")]
684 pub job: Option<JobToken>,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens685 /// The token's name, as its owner gave it, so a log can say which
686 /// token made a request. Absent where whoever resolved it did not say.
687 #[serde(default, skip_serializing_if = "Option::is_none")]
688 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 managers689 /// Set on a token narrowed to less than its owner can reach: one
690 /// workspace (all, selected or none of its private repositories), or
691 /// none at all (its owner's account and public repositories). Absent
692 /// on a token that reaches every workspace its owner can.
693 ///
694 /// The wire key is `fine_grained`, kept from before tokens were one
695 /// kind, so services deployed at different moments agree on it.
696 #[serde(rename = "fine_grained", default, skip_serializing_if = "Option::is_none")]
697 pub reach: Option<TokenReach>,
Token reach: workflow_files scope, fine-grained reach, workspace token cap698 /// Set on a workspace's own token that an owner gave Admin when making
699 /// it. Without it a workspace's token has Write on the workspace's
700 /// repositories, as a member would (see [`crate::access`]).
701 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
702 pub admin: bool,
703 /// Set on what a repository's deploy key resolves to: the key's id. Its
704 /// `repo` is the one repository it reaches.
705 #[serde(default, skip_serializing_if = "Option::is_none")]
706 pub deploy_key: Option<String>,
Merge main into Artifacts Phase 2707 /// Set on a person's token whose owner let it use the website (g1t.sh)
708 /// as them, sent as `Authorization: Bearer`. Not a scope: no preset,
709 /// full access or OAuth grant includes it, and git, the API and MCP
710 /// ignore it.
711 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
712 pub website: bool,
Token reach: workflow_files scope, fine-grained reach, workspace token cap713}
714
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers715/// Which repositories a token reaches in the workspace it is made for.
Token reach: workflow_files scope, fine-grained reach, workspace token cap716#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
717#[serde(rename_all = "snake_case")]
718pub enum RepositorySelection {
719 /// Every repository of the workspace, ones made later included.
720 #[default]
721 All,
722 /// The repositories chosen, by id.
723 Selected,
724 /// None of the workspace's private repositories: public repositories,
725 /// 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 managers726 /// With no workspace: the owner's account and public repositories only.
Token reach: workflow_files scope, fine-grained reach, workspace token cap727 Public,
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow728}
729
Token reach: workflow_files scope, fine-grained reach, workspace token cap730impl RepositorySelection {
731 pub fn as_str(self) -> &'static str {
732 match self {
733 RepositorySelection::All => "all",
734 RepositorySelection::Selected => "selected",
735 RepositorySelection::Public => "public",
736 }
737 }
738
739 pub fn parse(text: &str) -> Option<RepositorySelection> {
740 match text.trim().to_ascii_lowercase().as_str() {
741 "all" => Some(RepositorySelection::All),
742 "selected" => Some(RepositorySelection::Selected),
743 "public" | "public_only" | "none" => Some(RepositorySelection::Public),
744 _ => None,
745 }
746 }
747}
748
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers749/// What a narrowed token reaches, as identity resolves it on each use.
Token reach: workflow_files scope, fine-grained reach, workspace token cap750#[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 managers751pub struct TokenReach {
752 /// The workspace whose repositories and settings it reaches, by slug as
753 /// it is now. Absent: its owner's account only, with public
754 /// repositories read-only.
Token reach: workflow_files scope, fine-grained reach, workspace token cap755 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub workspace: Option<String>,
757 #[serde(default)]
758 pub repositories: RepositorySelection,
759 /// With [`RepositorySelection::Selected`]: the repositories' ids.
760 #[serde(default, skip_serializing_if = "Vec::is_empty")]
761 pub repo_ids: Vec<String>,
762}
763
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers764impl TokenReach {
Token reach: workflow_files scope, fine-grained reach, workspace token cap765 /// Whether it reaches the repository with this id in the workspace
766 /// `namespace` for more than what anyone may do with a public one.
767 pub fn covers(&self, repo_id: &str, namespace: &str) -> bool {
768 let Some(workspace) = self.workspace.as_deref() else {
769 return false;
770 };
771 if !workspace.eq_ignore_ascii_case(namespace) {
772 return false;
773 }
774 match self.repositories {
775 RepositorySelection::All => true,
776 RepositorySelection::Selected => self.repo_ids.iter().any(|id| id == repo_id),
777 RepositorySelection::Public => false,
778 }
779 }
780
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers781 /// Whether it is made for the workspace `slug`.
Token reach: workflow_files scope, fine-grained reach, workspace token cap782 pub fn owned_by(&self, slug: &str) -> bool {
783 self.workspace.as_deref().is_some_and(|workspace| workspace.eq_ignore_ascii_case(slug))
784 }
785}
786
787/// Where workflow files live. Adding, changing or deleting a file under
788/// one, with git or through g1t, needs [`Scope::WorkflowFilesWrite`] from a
789/// token: what GitHub's `workflow` scope and `workflows` permission do.
790pub const WORKFLOW_DIRS: [&str; 2] = [".g1t/workflows/", ".github/workflows/"];
791
792/// Whether `path` is a workflow file, or a file in one's directory.
793pub fn is_workflow_file(path: &str) -> bool {
794 let path = path.trim_start_matches('/');
795 WORKFLOW_DIRS.iter().any(|dir| {
796 path.len() >= dir.len() && path.is_char_boundary(dir.len()) && path[..dir.len()].eq_ignore_ascii_case(dir)
797 }) || WORKFLOW_DIRS.iter().any(|dir| path.eq_ignore_ascii_case(dir.trim_end_matches('/')))
798}
799
800/// Whether a token may add, change or delete the files at `paths`: a
801/// refusal naming the first workflow file it may not touch, else `None`.
802/// A signed-in person (no token) is never refused here; their role decides.
803pub fn decide_workflow_files<'a>(access: Option<&TokenAccess>, paths: impl IntoIterator<Item = &'a str>) -> Option<Decision> {
804 let access = access?;
805 if access.allows(Scope::WorkflowFilesWrite) && access.job.is_none() {
806 return None;
807 }
808 let path = paths.into_iter().find(|path| is_workflow_file(path))?;
809 let why = if access.job.is_some() {
810 "a workflow job's token can never add or change workflow files".to_owned()
811 } else {
812 format!("it needs the {} scope", Scope::WorkflowFilesWrite.as_str())
813 };
814 Some(Decision::deny(
815 "token:workflows",
816 format!("This access token cannot change the workflow file {path}: {why}."),
817 ))
818}
819
Merge branch 'worktree-agent-a3abfcce648e87dca'820/// The workflow job a token was made for.
821#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
822pub struct JobToken {
823 /// The run, `run_…`.
824 pub run_id: String,
825 /// The job, `job_…`.
826 pub job_id: String,
827 /// Whether it may open pull requests and approve them, by its
828 /// repository's and workspace's choice ("Allow g1t Actions to create and
829 /// approve pull requests"). Off unless chosen.
830 #[serde(default)]
831 pub pull_requests: bool,
832}
833
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow834impl TokenAccess {
835 /// Full access to everything: the access tokens made before scopes had.
836 pub fn full() -> Self {
837 TokenAccess::default()
838 }
839
Merge branch 'worktree-agent-a3abfcce648e87dca'840 /// Whether it may reach the repository `owner/name`: every token but a
841 /// workflow job's, which reaches its own repository only.
842 pub fn reaches(&self, repo: &str) -> bool {
843 self.repo.as_deref().is_none_or(|only| only.eq_ignore_ascii_case(repo))
844 }
845
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow846 pub fn is_full(&self) -> bool {
847 self.scopes.is_none()
848 }
849
850 /// The scopes it holds, or `None` for full access.
851 pub fn granted(&self) -> Option<Vec<Scope>> {
852 self.scopes
853 .as_ref()
854 .map(|scopes| scopes.iter().filter_map(|scope| Scope::parse(scope)).collect())
855 }
856
857 pub fn allows(&self, needed: Scope) -> bool {
858 match self.granted() {
859 None => true,
860 Some(granted) => granted.iter().any(|held| held.includes(needed)),
861 }
862 }
Token reach: workflow_files scope, fine-grained reach, workspace token cap863
864 /// 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 managers865 /// more than reading a public one: every token but a narrowed one
866 /// outside its workspace or repository selection. Its owner's role
Token reach: workflow_files scope, fine-grained reach, workspace token cap867 /// still decides; see [`crate::access`].
868 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 managers869 self.reach.as_ref().is_none_or(|reach| reach.covers(repo_id, namespace))
Token reach: workflow_files scope, fine-grained reach, workspace token cap870 }
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow871}
872
873/// Every operation of the API and MCP server, with the scope it needs. An
874/// operation in [`NO_SCOPE`] needs none. The API checks that every one of
875/// its operations is in exactly one of the two.
876pub const OPERATIONS: &[(&str, Scope)] = &[
877 // Your account.
878 ("list_emails", Scope::AccountRead),
879 ("add_email", Scope::AccountWrite),
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)880 ("confirm_email", Scope::AccountWrite),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow881 ("remove_email", Scope::AccountWrite),
882 ("update_email_settings", Scope::AccountWrite),
883 ("list_invites", Scope::AccountRead),
884 ("create_invite", Scope::AccountWrite),
885 ("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)886 ("list_invitations", Scope::AccountRead),
887 ("accept_invitation", Scope::AccountWrite),
888 ("decline_invitation", Scope::AccountWrite),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow889 ("list_my_repo_invitations", Scope::AccountRead),
890 ("accept_repo_invitation", Scope::AccountWrite),
891 ("decline_repo_invitation", Scope::AccountWrite),
API: pinned projects over REST and MCP892 // Your pinned projects: a preference of your account.
893 ("list_pinned_projects", Scope::AccountRead),
894 ("pin_project", Scope::AccountWrite),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97895 // Your stars: a preference of your account.
896 ("list_starred", Scope::AccountRead),
897 ("check_starred", Scope::AccountRead),
898 ("star_repo", Scope::AccountWrite),
899 ("unstar_repo", Scope::AccountWrite),
API: pinned projects over REST and MCP900 ("unpin_project", Scope::AccountWrite),
901 ("reorder_pinned_projects", Scope::AccountWrite),
API: notifications over REST and MCP, with notifications scopes902 // Your inbox: notifications, subscriptions and watching.
903 ("list_notifications", Scope::NotificationsRead),
904 ("get_notification_thread", Scope::NotificationsRead),
905 ("get_thread_subscription", Scope::NotificationsRead),
906 ("get_repo_subscription", Scope::NotificationsRead),
907 ("list_watched_repos", Scope::NotificationsRead),
908 ("mark_notifications_read", Scope::NotificationsWrite),
909 ("mark_thread_read", Scope::NotificationsWrite),
910 ("mark_thread_done", Scope::NotificationsWrite),
911 ("save_thread", Scope::NotificationsWrite),
912 ("snooze_thread", Scope::NotificationsWrite),
913 ("set_thread_subscription", Scope::NotificationsWrite),
914 ("delete_thread_subscription", Scope::NotificationsWrite),
915 ("set_repo_subscription", Scope::NotificationsWrite),
916 ("delete_repo_subscription", Scope::NotificationsWrite),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow917 // Workspaces, their invites and integrations.
918 ("create_workspace", Scope::WorkspaceAdmin),
919 ("delete_workspace", Scope::WorkspaceAdmin),
Merge branch 'worktree-agent-ad7c6d88d93adc817'920 ("get_workspace", Scope::WorkspaceRead),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily921 ("update_workspace", Scope::WorkspaceAdmin),
Merge main (membership, two-factor, GitHub repo roles) into tokens922 // Its members, and who owns it.
923 ("list_members", Scope::WorkspaceRead),
924 ("update_member", Scope::WorkspaceAdmin),
925 ("remove_member", Scope::WorkspaceAdmin),
926 ("transfer_ownership", Scope::WorkspaceAdmin),
927 ("leave_workspace", Scope::AccountWrite),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow928 ("list_workspace_invites", Scope::WorkspaceRead),
929 ("invite_member", Scope::WorkspaceAdmin),
930 ("revoke_workspace_invite", Scope::WorkspaceAdmin),
931 ("list_integrations", Scope::WorkspaceRead),
932 ("connect_integration", Scope::WorkspaceAdmin),
AI Gateway: OpenAI's format, open models, and your own providers933 ("update_integration", Scope::WorkspaceAdmin),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow934 ("disconnect_integration", Scope::WorkspaceAdmin),
935 ("test_integration", Scope::WorkspaceAdmin),
936 ("get_model_routes", Scope::WorkspaceRead),
937 ("set_model_routes", Scope::WorkspaceAdmin),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9938 // Customize: the marketplaces a workspace reads, the plugins it installs
939 // and the connectors it signs in. Members see them, owners change them,
940 // and a job's agent token reads the bundle its Claude Code loads.
941 ("list_marketplaces", Scope::WorkspaceRead),
942 ("add_marketplace", Scope::WorkspaceAdmin),
943 ("remove_marketplace", Scope::WorkspaceAdmin),
944 ("refresh_marketplace", Scope::WorkspaceAdmin),
945 ("discover", Scope::WorkspaceRead),
946 ("list_plugins", Scope::WorkspaceRead),
947 ("get_plugin", Scope::WorkspaceRead),
948 ("install_plugin", Scope::WorkspaceAdmin),
949 ("set_plugin_enabled", Scope::WorkspaceAdmin),
950 ("uninstall_plugin", Scope::WorkspaceAdmin),
951 ("update_plugin", Scope::WorkspaceAdmin),
952 ("list_plugin_agents", Scope::WorkspaceRead),
953 ("list_connectors", Scope::WorkspaceRead),
954 ("get_connector", Scope::WorkspaceRead),
955 ("add_connector", Scope::WorkspaceAdmin),
956 ("set_connector_key", Scope::WorkspaceAdmin),
957 ("remove_connector", Scope::WorkspaceAdmin),
958 ("refresh_connector", Scope::WorkspaceAdmin),
959 ("grant_connector", Scope::WorkspaceAdmin),
960 ("revoke_connector", Scope::WorkspaceAdmin),
961 ("customize_bundle", Scope::AgentsRun),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar962 // Teams: reading them, and managing them. A team's role on a
963 // repository is who has access.
964 ("list_teams", Scope::WorkspaceRead),
965 ("get_team", Scope::WorkspaceRead),
966 ("list_team_members", Scope::WorkspaceRead),
967 ("list_child_teams", Scope::WorkspaceRead),
968 ("list_team_repos", Scope::WorkspaceRead),
969 ("list_user_teams", Scope::WorkspaceRead),
970 ("create_team", Scope::WorkspaceAdmin),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge971 ("list_workspace_rulesets", Scope::WorkspaceRead),
972 ("get_workspace_ruleset", Scope::WorkspaceRead),
973 ("list_workspace_rule_evaluations", Scope::WorkspaceRead),
974 ("create_workspace_ruleset", Scope::WorkspaceAdmin),
975 ("update_workspace_ruleset", Scope::WorkspaceAdmin),
976 ("delete_workspace_ruleset", Scope::WorkspaceAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar977 ("update_team", Scope::WorkspaceAdmin),
978 ("delete_team", Scope::WorkspaceAdmin),
979 ("set_team_member", Scope::WorkspaceAdmin),
980 ("remove_team_member", Scope::WorkspaceAdmin),
981 ("set_team_review_assignment", Scope::WorkspaceAdmin),
Usage, Billing settings and prepaid AI credit; fixes from the UX audit982 // A workspace's billing: usage, budget, AI credit and invoices.
983 ("get_usage", Scope::BillingRead),
984 ("get_budget", Scope::BillingRead),
985 ("get_ai_credit", Scope::BillingRead),
986 ("list_invoices", Scope::BillingRead),
987 ("get_billing_details", Scope::BillingRead),
988 ("set_budget", Scope::BillingWrite),
989 ("buy_ai_credit", Scope::BillingWrite),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow990 // Repositories.
991 ("list_repos", Scope::RepoRead),
992 ("get_repo", Scope::RepoRead),
Merge branch 'worktree-agent-a19b06d959c105bf7' into claude/cool-newton-7nm8r9993 // A repository's files, as clone and fetch need.
994 ("download_tarball_archive", Scope::CodeRead),
995 ("download_zipball_archive", Scope::CodeRead),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97996 // Projects follow their repositories.
997 ("list_projects", Scope::RepoRead),
998 ("get_project", Scope::RepoRead),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow999 ("search", Scope::RepoRead),
1000 ("list_events", Scope::RepoRead),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971001 // What the default branch says about a repository, who starred it, and
1002 // its releases.
1003 ("get_languages", Scope::RepoRead),
1004 ("list_contributors", Scope::RepoRead),
1005 ("get_license", Scope::RepoRead),
1006 ("list_stargazers", Scope::RepoRead),
1007 ("list_releases", Scope::RepoRead),
1008 ("get_latest_release", Scope::RepoRead),
1009 ("get_release_by_tag", Scope::RepoRead),
1010 ("get_release", Scope::RepoRead),
1011 ("create_release", Scope::RepoWrite),
1012 ("update_release", Scope::RepoWrite),
1013 ("delete_release", Scope::RepoWrite),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1014 ("list_labels", Scope::RepoRead),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1015 ("list_milestones", Scope::RepoRead),
1016 ("get_milestone", Scope::RepoRead),
1017 ("create_label", Scope::IssuesWrite),
1018 ("update_label", Scope::IssuesWrite),
1019 ("delete_label", Scope::IssuesWrite),
1020 ("add_default_labels", Scope::IssuesWrite),
1021 ("create_milestone", Scope::IssuesWrite),
1022 ("update_milestone", Scope::IssuesWrite),
1023 ("delete_milestone", Scope::IssuesWrite),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1024 ("get_repo_settings", Scope::RepoRead),
Fast pages, required checks on the branch, self-hosted runners, honest incidents1025 ("list_check_names", Scope::RepoRead),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1026 ("list_deleted_repos", Scope::RepoRead),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1027 ("list_security_alerts", Scope::RepoRead),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1028 ("get_codeowners_errors", Scope::RepoRead),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1029 ("create_repo", Scope::RepoWrite),
1030 ("update_repo", Scope::RepoWrite),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971031 ("update_project", Scope::RepoWrite),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1032 ("update_repo_settings", Scope::RepoWrite),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1033 // Rulesets: reading them is reading the repository; changing them
1034 // changes what everyone, agents included, may do, so it is admin.
1035 ("list_repo_rulesets", Scope::RepoRead),
1036 ("get_repo_ruleset", Scope::RepoRead),
1037 ("get_branch_rules", Scope::RepoRead),
1038 ("list_rule_evaluations", Scope::RepoRead),
1039 ("create_repo_ruleset", Scope::RepoAdmin),
1040 ("update_repo_ruleset", Scope::RepoAdmin),
1041 ("delete_repo_ruleset", Scope::RepoAdmin),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1042 ("rename_branch", Scope::RepoWrite),
1043 ("rename_repo", Scope::RepoAdmin),
1044 ("transfer_repo", Scope::RepoAdmin),
1045 ("archive_repo", Scope::RepoAdmin),
1046 ("unarchive_repo", Scope::RepoAdmin),
1047 ("set_repo_visibility", Scope::RepoAdmin),
1048 ("delete_repo", Scope::RepoAdmin),
1049 ("restore_repo", Scope::RepoAdmin),
1050 ("purge_repo", Scope::RepoAdmin),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1051 // A dismissed secret is let through push protection.
1052 ("dismiss_security_alert", Scope::RepoAdmin),
1053 ("reopen_security_alert", Scope::RepoAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1054 // The security suite: alerts, push protection, patterns, code
1055 // scanning, the supply chain and settings.
1056 ("list_secret_scanning_alerts", Scope::SecurityRead),
1057 ("get_secret_scanning_alert", Scope::SecurityRead),
1058 ("list_secret_scanning_locations", Scope::SecurityRead),
1059 ("list_bypass_requests", Scope::SecurityRead),
1060 ("list_custom_patterns", Scope::SecurityRead),
1061 ("list_code_scanning_alerts", Scope::SecurityRead),
1062 ("get_code_scanning_alert", Scope::SecurityRead),
1063 ("list_code_scanning_analyses", Scope::SecurityRead),
1064 ("get_sarif_upload", Scope::SecurityRead),
1065 ("list_vulnerability_alerts", Scope::SecurityRead),
1066 ("get_vulnerability_alert", Scope::SecurityRead),
1067 ("get_dependency_graph", Scope::SecurityRead),
1068 ("get_sbom", Scope::SecurityRead),
1069 ("compare_dependencies", Scope::SecurityRead),
1070 ("get_security_settings", Scope::SecurityRead),
1071 ("get_workspace_security_settings", Scope::SecurityRead),
1072 ("get_security_overview", Scope::SecurityRead),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r91073 ("list_security_advisories", Scope::SecurityRead),
1074 ("get_security_advisory", Scope::SecurityRead),
1075 ("list_vulnerability_reports", Scope::SecurityRead),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1076 ("update_secret_scanning_alert", Scope::SecurityWrite),
1077 ("bypass_push_protection", Scope::SecurityWrite),
1078 ("check_secret_validity", Scope::SecurityWrite),
1079 ("review_bypass_request", Scope::SecurityWrite),
1080 ("create_custom_pattern", Scope::SecurityWrite),
1081 ("update_custom_pattern", Scope::SecurityWrite),
1082 ("delete_custom_pattern", Scope::SecurityWrite),
1083 ("dry_run_custom_pattern", Scope::SecurityWrite),
1084 ("update_code_scanning_alert", Scope::SecurityWrite),
1085 ("upload_sarif", Scope::SecurityWrite),
1086 ("update_vulnerability_alert", Scope::SecurityWrite),
1087 ("fix_security_alert", Scope::SecurityWrite),
1088 ("update_security_settings", Scope::SecurityWrite),
1089 ("update_workspace_security_settings", Scope::SecurityWrite),
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r91090 ("create_security_advisory", Scope::SecurityWrite),
1091 ("update_security_advisory", Scope::SecurityWrite),
1092 ("set_security_advisory_state", Scope::SecurityWrite),
1093 ("report_vulnerability", Scope::SecurityWrite),
1094 ("review_vulnerability_report", Scope::SecurityWrite),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1095 // Issues and plans.
1096 ("list_issues", Scope::IssuesRead),
1097 ("get_issue", Scope::IssuesRead),
1098 ("get_plan", Scope::IssuesRead),
1099 ("create_issue", Scope::IssuesWrite),
1100 ("update_issue", Scope::IssuesWrite),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1101 ("list_issue_labels", Scope::IssuesRead),
1102 ("add_issue_labels", Scope::IssuesWrite),
1103 ("set_issue_labels", Scope::IssuesWrite),
1104 ("remove_issue_labels", Scope::IssuesWrite),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1105 ("close_issue", Scope::IssuesWrite),
1106 ("reopen_issue", Scope::IssuesWrite),
1107 ("add_comment", Scope::IssuesWrite),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1108 ("edit_comment", Scope::IssuesWrite),
1109 ("delete_comment", Scope::IssuesWrite),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1110 ("import_issue", Scope::IssuesWrite),
1111 ("apply_plan", Scope::IssuesWrite),
1112 // Pull requests.
1113 ("list_pull_requests", Scope::PullRequestsRead),
1114 ("get_pull_request", Scope::PullRequestsRead),
1115 ("get_pull_request_changes", Scope::PullRequestsRead),
1116 ("read_session", Scope::PullRequestsRead),
1117 ("get_merge_queue", Scope::PullRequestsRead),
1118 ("create_pull_request", Scope::PullRequestsWrite),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1119 ("update_pull_request", Scope::PullRequestsWrite),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1120 ("record_session", Scope::PullRequestsWrite),
1121 ("mark_pull_request_ready", Scope::PullRequestsWrite),
1122 ("close_pull_request", Scope::PullRequestsWrite),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1123 ("reopen_pull_request", Scope::PullRequestsWrite),
1124 ("convert_pull_request_to_draft", Scope::PullRequestsWrite),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1125 ("review_pull_request", Scope::PullRequestsWrite),
1126 ("merge_pull_request", Scope::PullRequestsWrite),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1127 ("request_reviewers", Scope::PullRequestsWrite),
1128 ("remove_requested_reviewers", Scope::PullRequestsWrite),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1129 // g1t's agents.
1130 ("assign_issue", Scope::AgentsRun),
1131 ("delegate", Scope::AgentsRun),
1132 ("plan_work", Scope::AgentsRun),
1133 ("message_agent", Scope::AgentsRun),
1134 ("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.1135 // A workspace agent's own computer: waking it spends the workspace's
1136 // money, so the same scope covers reading and managing it.
1137 ("get_agent_computer", Scope::AgentsRun),
1138 ("wake_agent_computer", Scope::AgentsRun),
1139 ("sleep_agent_computer", Scope::AgentsRun),
1140 ("reset_agent_computer", Scope::AgentsRun),
1141 ("list_agent_computer_commands", Scope::AgentsRun),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1142 ("take_messages", Scope::AgentsRun),
1143 // Workflows.
1144 ("list_workflows", Scope::WorkflowsRead),
1145 ("list_workflow_runs", Scope::WorkflowsRead),
1146 ("get_workflow_run", Scope::WorkflowsRead),
1147 ("get_job_logs", Scope::WorkflowsRead),
1148 ("dispatch_workflow", Scope::WorkflowsWrite),
1149 ("cancel_workflow_run", Scope::WorkflowsWrite),
1150 ("rerun_workflow_run", Scope::WorkflowsWrite),
1151 ("update_workflow", Scope::WorkflowsWrite),
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R21152 ("list_artifacts", Scope::WorkflowsRead),
1153 ("list_workflow_run_artifacts", Scope::WorkflowsRead),
1154 ("get_artifact", Scope::WorkflowsRead),
1155 ("download_artifact", Scope::WorkflowsRead),
1156 ("get_artifact_retention", Scope::WorkflowsRead),
1157 ("delete_artifact", Scope::WorkflowsWrite),
1158 ("set_artifact_retention", Scope::WorkflowsWrite),
Merge checks: statuses and check runs on every commit1159 // Checks: statuses, check runs and check suites on commits.
1160 ("list_commit_statuses", Scope::ChecksRead),
1161 ("get_combined_status", Scope::ChecksRead),
1162 ("list_check_runs_for_ref", Scope::ChecksRead),
1163 ("get_check_run", Scope::ChecksRead),
1164 ("list_check_run_annotations", Scope::ChecksRead),
1165 ("list_check_suites_for_ref", Scope::ChecksRead),
1166 ("get_check_suite", Scope::ChecksRead),
1167 ("create_commit_status", Scope::ChecksWrite),
1168 ("create_check_run", Scope::ChecksWrite),
1169 ("update_check_run", Scope::ChecksWrite),
1170 ("rerequest_check_run", Scope::ChecksWrite),
1171 ("rerequest_check_suite", Scope::ChecksWrite),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971172 // Deployments, wherever they run: reading them, and reporting them.
1173 ("list_deployments", Scope::DeploymentsRead),
1174 ("get_deployment", Scope::DeploymentsRead),
1175 ("list_deployment_statuses", Scope::DeploymentsRead),
1176 ("list_environments", Scope::DeploymentsRead),
1177 ("get_environment", Scope::DeploymentsRead),
1178 ("create_deployment", Scope::DeploymentsWrite),
1179 ("create_deployment_status", Scope::DeploymentsWrite),
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r91180 ("start_deployment_build", Scope::DeploymentsWrite),
Merge branch 'worktree-agent-a3abfcce648e87dca'1181 // What keeps runs safe: the runs environments hold and reviewing them,
1182 // approving a pull request's run, and a repository's own rules for
1183 // its environments and tokens, which are an admin's.
1184 ("get_pending_deployments", Scope::WorkflowsRead),
1185 ("review_pending_deployments", Scope::WorkflowsWrite),
1186 ("approve_workflow_run", Scope::WorkflowsWrite),
1187 ("get_workflow_permissions", Scope::RepoRead),
1188 ("get_fork_pr_approval", Scope::RepoRead),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1189 ("get_actions_access", Scope::RepoRead),
Merge branch 'worktree-agent-a3abfcce648e87dca'1190 ("update_environment", Scope::RepoAdmin),
1191 ("delete_environment", Scope::RepoAdmin),
1192 ("set_workflow_permissions", Scope::RepoAdmin),
1193 ("set_fork_pr_approval", Scope::RepoAdmin),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1194 ("set_actions_access", Scope::RepoAdmin),
Merge branch 'worktree-agent-a3abfcce648e87dca'1195 // Starting workflows from outside, as a push would.
1196 ("create_repository_dispatch", Scope::CodeWrite),
1197 // A workspace's policy for its repositories' tokens.
1198 ("get_workspace_workflow_permissions", Scope::WorkspaceRead),
1199 ("set_workspace_workflow_permissions", Scope::WorkspaceAdmin),
Merge branch 'worktree-agent-a209db8188a251e77' into claude/cool-newton-7nm8r91200 // The workflows a workspace runs in its repositories: its owners'.
1201 ("list_workspace_managed_workflows", Scope::WorkspaceRead),
1202 ("create_workspace_managed_workflow", Scope::WorkspaceAdmin),
1203 ("update_workspace_managed_workflow", Scope::WorkspaceAdmin),
1204 ("delete_workspace_managed_workflow", Scope::WorkspaceAdmin),
API and MCP for a workspace's personal access token rules, members' tokens and approvals1205 // A workspace's rules for personal access tokens, and the members'
1206 // tokens that reach it: who has access.
1207 ("get_token_policy", Scope::WorkspaceRead),
1208 ("set_token_policy", Scope::WorkspaceAdmin),
1209 ("list_member_tokens", Scope::AccessRead),
1210 ("list_token_requests", Scope::AccessRead),
1211 ("review_token_request", Scope::AccessAdmin),
1212 ("revoke_member_token", Scope::AccessAdmin),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1213 // Memory and the context hub.
1214 ("recall", Scope::MemoryRead),
1215 ("search_context", Scope::MemoryRead),
1216 ("get_entity", Scope::MemoryRead),
1217 ("get_context", Scope::MemoryRead),
1218 ("remember", Scope::MemoryWrite),
1219 // Who has access.
1220 ("list_collaborators", Scope::AccessRead),
1221 ("get_collaborator_permission", Scope::AccessRead),
1222 ("list_repo_invitations", Scope::AccessRead),
1223 ("list_outside_collaborators", Scope::AccessRead),
1224 ("add_collaborator", Scope::AccessAdmin),
1225 ("update_collaborator", Scope::AccessAdmin),
1226 ("remove_collaborator", Scope::AccessAdmin),
1227 ("revoke_repo_invitation", Scope::AccessAdmin),
1228 ("set_base_permission", Scope::AccessAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1229 ("set_team_repo", Scope::AccessAdmin),
1230 ("remove_team_repo", Scope::AccessAdmin),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1231 // Deploy keys: each lets a machine reach one repository, so they
1232 // are part of who has access.
1233 ("list_deploy_keys", Scope::AccessRead),
1234 ("get_deploy_key", Scope::AccessRead),
1235 ("create_deploy_key", Scope::AccessAdmin),
1236 ("delete_deploy_key", Scope::AccessAdmin),
Merge branch 'mirroring' into artifacts-mode1237 // Mirroring: a repository's links to other hosts. Reading them is
1238 // reading the repository; syncing writes code; the rest is an admin's.
1239 ("get_mirror", Scope::RepoRead),
1240 ("sync_mirror", Scope::CodeWrite),
1241 ("get_hand_back_plan", Scope::RepoAdmin),
1242 ("take_over_mirror", Scope::RepoAdmin),
1243 ("set_ci_failover", Scope::RepoAdmin),
1244 ("hand_back_mirror", Scope::RepoAdmin),
1245 ("move_mirror_to_g1t", Scope::RepoAdmin),
1246 ("add_mirror_remote", Scope::RepoAdmin),
1247 ("update_mirror_remote", Scope::RepoAdmin),
1248 ("remove_mirror_remote", Scope::RepoAdmin),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1249 // Webhooks.
1250 ("list_webhooks", Scope::WebhooksRead),
1251 ("list_webhook_deliveries", Scope::WebhooksRead),
1252 ("create_webhook", Scope::WebhooksAdmin),
1253 ("update_webhook", Scope::WebhooksAdmin),
1254 ("delete_webhook", Scope::WebhooksAdmin),
1255 ("ping_webhook", Scope::WebhooksAdmin),
1256 ("redeliver_webhook", Scope::WebhooksAdmin),
1257 // Secrets and variables.
1258 ("list_actions_secrets", Scope::SecretsRead),
1259 ("list_actions_variables", Scope::SecretsRead),
1260 ("set_actions_secret", Scope::SecretsAdmin),
1261 ("delete_actions_secret", Scope::SecretsAdmin),
1262 ("set_actions_variable", Scope::SecretsAdmin),
1263 ("delete_actions_variable", Scope::SecretsAdmin),
Fast pages, required checks on the branch, self-hosted runners, honest incidents1264 // Self-hosted runners.
1265 ("list_runners", Scope::RunnersRead),
1266 ("list_runner_groups", Scope::RunnersRead),
1267 ("get_runner_settings", Scope::RunnersRead),
1268 ("create_runner_registration_token", Scope::RunnersAdmin),
1269 ("remove_runner", Scope::RunnersAdmin),
1270 ("create_runner_group", Scope::RunnersAdmin),
1271 ("update_runner_group", Scope::RunnersAdmin),
1272 ("delete_runner_group", Scope::RunnersAdmin),
1273 ("update_runner_settings", Scope::RunnersAdmin),
Merge packages: roles, Actions access, source label, soft delete, API1274 // Packages: reading them, their versions and who may use them needs
1275 // `packages:read`; changing their settings, access and Manage Actions
1276 // access `packages:write` (and the Admin role on the package, which the
1277 // packages service checks); deleting and restoring packages and
1278 // versions `packages:delete`, as the registries' own deletes do.
1279 ("list_packages", Scope::PackagesRead),
1280 ("get_package", Scope::PackagesRead),
1281 ("list_package_versions", Scope::PackagesRead),
1282 ("get_package_version", Scope::PackagesRead),
1283 ("list_package_access", Scope::PackagesRead),
1284 ("list_package_actions_access", Scope::PackagesRead),
1285 ("update_package", Scope::PackagesWrite),
1286 ("link_package", Scope::PackagesWrite),
1287 ("unlink_package", Scope::PackagesWrite),
1288 ("set_package_access", Scope::PackagesWrite),
1289 ("remove_package_access", Scope::PackagesWrite),
1290 ("set_package_actions_access", Scope::PackagesWrite),
1291 ("remove_package_actions_access", Scope::PackagesWrite),
1292 ("delete_package", Scope::PackagesDelete),
1293 ("restore_package", Scope::PackagesDelete),
1294 ("delete_package_version", Scope::PackagesDelete),
1295 ("restore_package_version", Scope::PackagesDelete),
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1296 // The AI Gateway. Sending a request to a model needs `models:write`,
1297 // checked by the model proxy at models.g1t.sh, not here.
1298 ("list_gateway_requests", Scope::ModelsRead),
Merge main into Artifacts Phase 21299 // Artifacts mode's docs, slides, designs and dashboards (the `artifact`
1300 // MCP tool). Reading takes artifacts:read, making and changing them
1301 // 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.1302 // artifacts:admin. The artifacts service then checks the person's own role
Merge main into Artifacts Phase 21303 // on each one.
1304 ("list_workspace_artifacts", Scope::ArtifactsRead),
1305 ("search_workspace_artifacts", Scope::ArtifactsRead),
1306 ("get_workspace_artifact", Scope::ArtifactsRead),
1307 ("get_workspace_artifact_content", Scope::ArtifactsRead),
1308 ("list_workspace_artifact_versions", Scope::ArtifactsRead),
1309 ("get_workspace_artifact_access", Scope::ArtifactsRead),
1310 ("list_workspace_artifact_templates", Scope::ArtifactsRead),
1311 ("list_workspace_artifact_spaces", Scope::ArtifactsRead),
1312 ("query_workspace_dataset", Scope::ArtifactsRead),
1313 ("create_workspace_artifact", Scope::ArtifactsWrite),
1314 ("update_workspace_artifact", Scope::ArtifactsWrite),
1315 ("edit_workspace_artifact", Scope::ArtifactsWrite),
1316 ("trash_workspace_artifact", Scope::ArtifactsWrite),
1317 ("restore_workspace_artifact", Scope::ArtifactsWrite),
1318 ("restore_workspace_artifact_version", Scope::ArtifactsWrite),
1319 ("set_workspace_artifact_access", Scope::ArtifactsAdmin),
1320 ("purge_workspace_artifact", Scope::ArtifactsAdmin),
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1321];
1322
1323/// Operations any token may use: saying who it is.
1324pub const NO_SCOPE: &[&str] = &["whoami"];
1325
1326/// The scope `operation` needs. `None` for one in [`NO_SCOPE`]; an
1327/// operation in neither list needs full access.
1328pub fn scope_for(operation: &str) -> Option<Scope> {
1329 OPERATIONS
1330 .iter()
1331 .find(|(name, _)| *name == operation)
1332 .map(|(_, scope)| *scope)
1333}
1334
1335/// What a token needs for `operation` with this input beyond its own
1336/// scope: starting agents from an operation that can, and making a
1337/// repository public or private.
1338pub fn extra_scopes(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1339 let mut extra = Vec::new();
1340 let assigns = input["assign"].as_bool() == Some(true)
1341 || input["agent"].as_bool() == Some(true)
1342 || input["assign_agent"].as_bool() == Some(true);
1343 if assigns && matches!(operation, "apply_plan" | "import_issue" | "create_issue") {
1344 extra.push(Scope::AgentsRun);
1345 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1346 // Fixing an alert opens an issue and puts g1t on it.
1347 if operation == "fix_security_alert" {
1348 extra.extend([Scope::IssuesWrite, Scope::AgentsRun]);
1349 }
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1350 // Opening the issue an agent is put on.
1351 if operation == "delegate" {
1352 extra.push(Scope::IssuesWrite);
1353 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1354 // A workspace's base permission is who has access.
1355 if operation == "update_workspace" && input.get("base_permission").is_some_and(|v| !v.is_null()) {
1356 extra.push(Scope::AccessAdmin);
1357 }
Merge checks: statuses and check runs on every commit1358 // Asking a g1t Actions job or run to run again reruns its workflow.
1359 if matches!(operation, "rerequest_check_run" | "rerequest_check_suite")
1360 && input["id"].as_str().is_some_and(|id| id.starts_with("job_") || id.starts_with("run_"))
1361 {
1362 extra.push(Scope::WorkflowsWrite);
1363 }
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1364 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())) {
1365 extra.push(Scope::RepoAdmin);
1366 }
1367 extra
1368}
1369
1370/// The scopes a call needs, its own first.
1371pub fn needed(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1372 scope_for(operation)
1373 .into_iter()
1374 .chain(extra_scopes(operation, input))
1375 .collect()
1376}
1377
1378/// Whether `access` may use `operation` with `input`. The person's (or
1379/// workspace's) role is checked after this, by the service that owns what
1380/// was asked about.
1381pub fn decide(access: &TokenAccess, operation: &str, input: &serde_json::Value) -> Decision {
1382 let rule = if access.legacy { "token:legacy" } else { "token:scope" };
Merge branch 'worktree-agent-a3abfcce648e87dca'1383 // A workflow job may open or approve pull requests only where its
1384 // repository and workspace let it, as on GitHub.
1385 if let Some(job) = &access.job
1386 && !job.pull_requests
1387 && (operation == "create_pull_request" || (operation == "review_pull_request" && input["verdict"].as_str() == Some("approve")))
1388 {
1389 return Decision::deny(
1390 "token:pull-requests",
1391 "A workflow job cannot open or approve pull requests here: an admin can allow it under Settings, Actions.",
1392 );
1393 }
1394 if let Some(only) = access.repo.as_deref()
1395 && !NO_SCOPE.contains(&operation)
1396 {
1397 match input["repo"].as_str() {
1398 Some(repo) if access.reaches(repo) => {}
1399 Some(repo) => {
1400 return Decision::deny("token:repository", format!("This token is a workflow job's in {only}: it cannot reach {repo}."));
1401 }
1402 None => {
1403 return Decision::deny("token:repository", format!("This token is a workflow job's: it reaches only {only}, and {operation} is not about one repository."));
1404 }
1405 }
1406 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1407 // A token made for one workspace (or none) only reads outside it:
1408 // public repositories, as anyone may. Inside it, its repository
1409 // selection is checked with its owner's role (`access::granted`).
1410 if let Some(reach) = &access.reach
Token reach: workflow_files scope, fine-grained reach, workspace token cap1411 && let Some(repo) = input["repo"].as_str()
1412 && !NO_SCOPE.contains(&operation)
1413 {
1414 let namespace = repo.split('/').next().unwrap_or_default();
1415 let changes = needed(operation, input).iter().any(|scope| scope.level() != Level::Read);
1416 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 managers1417 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 cap1418 return Decision::deny(
1419 "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 managers1420 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 cap1421 );
1422 }
1423 }
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1424 if access.scopes.is_some() {
1425 let known = NO_SCOPE.contains(&operation) || scope_for(operation).is_some();
1426 if !known {
1427 return Decision::deny("token:scope", format!("This access token cannot use {operation}: it needs full access."));
1428 }
1429 if let Some(missing) = needed(operation, input).into_iter().find(|scope| !access.allows(*scope)) {
1430 return Decision::deny(
1431 "token:scope",
1432 format!("This access token needs the {} scope to use {operation}.", missing.as_str()),
1433 );
1434 }
1435 }
1436 Decision::allow(rule)
1437}
1438
Merge branch 'worktree-agent-a3abfcce648e87dca'1439/// Whether a token may use the repository `owner/name` at all: a refusal
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1440/// for a workflow job's token or a deploy key in another repository,
1441/// else `None`. Git and
Merge branch 'worktree-agent-a3abfcce648e87dca'1442/// the package registries ask this before [`decide_git`] and
1443/// [`decide_packages`].
1444pub fn decide_repo(access: &TokenAccess, repo: &str) -> Option<Decision> {
1445 let only = access.repo.as_deref()?;
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1446 let why = if access.deploy_key.is_some() {
1447 format!("This deploy key is for {only}: it cannot reach {repo}.")
1448 } else {
1449 format!("This token is a workflow job's in {only}: it cannot reach {repo}.")
1450 };
1451 (!access.reaches(repo)).then(|| Decision::deny("token:repository", why))
Merge branch 'worktree-agent-a3abfcce648e87dca'1452}
1453
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1454/// Whether a token may clone or fetch (`write` false), or push to (`write`
1455/// true), a repository with git. `public` is whether anyone may read it,
1456/// which needs no scope.
1457pub fn decide_git(access: &TokenAccess, write: bool, public: bool) -> Decision {
1458 let needed = if write { Scope::CodeWrite } else { Scope::CodeRead };
1459 if !access.allows(needed) && (write || !public) {
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1460 if access.deploy_key.is_some() {
1461 return Decision::deny(
1462 "token:scope",
1463 "This deploy key is read-only. An admin of the repository can add it again with write access to push with it.",
1464 );
1465 }
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1466 return Decision::deny(
1467 "token:scope",
1468 format!("This access token needs the {} scope to {} with git.", needed.as_str(), if write { "push" } else { "clone or fetch a private repository" }),
1469 );
1470 }
1471 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1472}
1473
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1474/// Whether a token may pull (`Level::Read`), push or publish
1475/// (`Level::Write`), or delete (`Level::Delete`) packages. `public` is
1476/// whether anyone may pull the package, which needs no scope.
1477pub fn decide_packages(access: &TokenAccess, level: Level, public: bool) -> Decision {
1478 let (needed, doing) = match level {
1479 Level::Read => (Scope::PackagesRead, "pull a private package"),
1480 Level::Delete | Level::Admin => (Scope::PackagesDelete, "delete packages"),
1481 Level::Write | Level::Run => (Scope::PackagesWrite, "push or publish packages"),
1482 };
1483 if !access.allows(needed) && !(level == Level::Read && public) {
1484 return Decision::deny(
1485 "token:scope",
1486 format!("This access token needs the {} scope to {doing}.", needed.as_str()),
1487 );
1488 }
1489 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1490}
1491
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1492#[cfg(test)]
1493mod tests {
1494 use super::*;
1495 use serde_json::json;
1496
1497 fn token(scopes: &[Scope]) -> TokenAccess {
1498 TokenAccess {
1499 token_id: "tok_1".to_owned(),
1500 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
1501 legacy: false,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1502 name: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1503 ..TokenAccess::default()
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1504 }
1505 }
1506
1507 #[test]
1508 fn every_scope_reads_back_and_belongs_to_a_resource() {
1509 for scope in Scope::ALL {
1510 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
1511 assert!(scope.as_str().starts_with(scope.resource().as_str()));
1512 assert!(scope.includes(scope));
1513 }
1514 assert_eq!(Scope::parse(" Issues:Write "), Some(Scope::IssuesWrite));
1515 assert_eq!(Scope::parse("issues"), None);
1516 }
1517
1518 #[test]
1519 fn a_higher_level_includes_the_lower_ones_of_its_resource_only() {
1520 assert!(Scope::RepoAdmin.includes(Scope::RepoRead));
1521 assert!(Scope::RepoAdmin.includes(Scope::RepoWrite));
1522 assert!(Scope::IssuesWrite.includes(Scope::IssuesRead));
1523 assert!(!Scope::IssuesRead.includes(Scope::IssuesWrite));
1524 assert!(!Scope::RepoAdmin.includes(Scope::CodeWrite));
1525 assert!(!Scope::PullRequestsWrite.includes(Scope::IssuesWrite));
1526 }
1527
1528 #[test]
1529 fn operations_are_listed_once_and_never_also_free() {
1530 let mut seen = std::collections::HashSet::new();
1531 for (name, _) in OPERATIONS {
1532 assert!(seen.insert(*name), "{name} twice");
1533 assert!(!NO_SCOPE.contains(name), "{name}");
1534 }
1535 }
1536
1537 #[test]
1538 fn scopes_are_parsed_from_oauth_text_leaving_out_unknown_ones() {
1539 assert_eq!(
1540 parse_scopes("issues:write repo:read,bogus:thing issues:write"),
1541 vec![Scope::RepoRead, Scope::IssuesWrite]
1542 );
1543 assert_eq!(scopes_text(&[Scope::RepoRead, Scope::IssuesWrite]), "repo:read issues:write");
1544 }
1545
1546 #[test]
1547 fn the_oauth_default_is_the_agent_preset_and_never_admin() {
1548 let scopes = oauth_default();
1549 assert!(scopes.contains(&Scope::IssuesWrite));
1550 assert!(scopes.contains(&Scope::PullRequestsWrite));
1551 assert!(scopes.contains(&Scope::AgentsRun));
1552 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 yet1553 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)1554 // Every read offered but the machines work runs on, and chat.
1555 assert_eq!(scopes.contains(&read), read != Scope::RunnersRead && read != Scope::ChatRead, "{read:?}");
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1556 }
1557 assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read));
1558 assert_eq!(Preset::Full.scopes(), None);
1559 }
1560
1561 #[test]
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1562 fn billing_is_read_by_presets_and_changed_by_none_but_full_access() {
1563 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::BillingRead));
1564 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1565 assert!(!preset.scopes().unwrap().contains(&Scope::BillingWrite), "{}", preset.as_str());
1566 }
1567 assert_eq!(scope_for("set_budget"), Some(Scope::BillingWrite));
1568 assert_eq!(scope_for("buy_ai_credit"), Some(Scope::BillingWrite));
1569 assert_eq!(scope_for("get_usage"), Some(Scope::BillingRead));
1570 let reader = token(&[Scope::BillingRead]);
1571 assert!(decide(&reader, "list_invoices", &json!({})).allowed);
1572 assert!(decide(&reader, "set_budget", &json!({})).reason.unwrap().contains("billing:write"));
1573 }
1574
1575 #[test]
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1576 fn the_ai_gateway_spends_only_with_models_write_which_no_preset_gives() {
1577 // Reading the log is a read like any other.
1578 assert_eq!(scope_for("list_gateway_requests"), Some(Scope::ModelsRead));
1579 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::ModelsRead));
1580 // Sending requests spends the workspace's AI credit: chosen on purpose.
1581 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1582 assert!(!preset.scopes().unwrap().contains(&Scope::ModelsWrite), "{}", preset.as_str());
1583 }
1584 assert!(Scope::ModelsWrite.includes(Scope::ModelsRead));
1585 assert!(!Scope::ModelsWrite.dangerous());
1586 assert!(token(&[Scope::ModelsWrite]).allows(Scope::ModelsWrite));
1587 assert!(!token(&[Scope::BillingWrite]).allows(Scope::ModelsWrite));
1588 assert!(TokenAccess::full().allows(Scope::ModelsWrite));
1589 }
1590
1591 #[test]
Merge main into Artifacts Phase 21592 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 yet1593 for scope in [Scope::ArtifactsRead, Scope::ArtifactsWrite, Scope::ArtifactsAdmin] {
1594 assert_eq!(scope.resource(), Resource::Artifacts);
Merge main into Artifacts Phase 21595 assert!(scope.offered());
1596 assert!(offered_scopes().contains(&scope));
1597 assert_eq!(parse_scopes(scope.as_str()), vec![scope]);
1598 assert!(OPERATIONS.iter().any(|(_, needed)| *needed == scope), "{scope:?} gates nothing");
1599 }
1600 // Reading them is a read like any other; changing them is chosen.
1601 for preset in [Preset::ReadOnly, Preset::Agent] {
1602 let scopes = preset.scopes().unwrap();
1603 assert!(scopes.contains(&Scope::ArtifactsRead), "{}", preset.as_str());
1604 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 yet1605 }
Merge main into Artifacts Phase 21606 assert!(!Preset::Ci.scopes().unwrap().contains(&Scope::ArtifactsRead));
1607 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 yet1608 assert!(Scope::ArtifactsAdmin.includes(Scope::ArtifactsWrite));
1609 assert!(Scope::ArtifactsAdmin.dangerous());
Merge main into Artifacts Phase 21610 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 yet1611 assert_eq!(Resource::Artifacts.group(), ResourceGroup::Workspace);
Merge main into Artifacts Phase 21612 let asked = std::collections::BTreeMap::from([("artifacts".to_owned(), "write".to_owned())]);
1613 assert_eq!(resolve_permissions(&asked, true), Ok(vec![Scope::ArtifactsWrite]));
1614 assert_eq!(offered_scopes().len(), Scope::ALL.len());
1615 // Sharing and deleting for good need admin; editing needs write.
1616 assert_eq!(scope_for("set_workspace_artifact_access"), Some(Scope::ArtifactsAdmin));
1617 assert_eq!(scope_for("purge_workspace_artifact"), Some(Scope::ArtifactsAdmin));
1618 assert_eq!(scope_for("edit_workspace_artifact"), Some(Scope::ArtifactsWrite));
1619 assert_eq!(scope_for("get_workspace_artifact_content"), Some(Scope::ArtifactsRead));
1620 let writer = token(&[Scope::ArtifactsWrite]);
1621 assert!(decide(&writer, "edit_workspace_artifact", &json!({})).allowed);
1622 assert!(decide(&writer, "list_workspace_artifacts", &json!({})).allowed, "write includes read");
1623 assert!(decide(&writer, "set_workspace_artifact_access", &json!({})).reason.unwrap().contains("artifacts:admin"));
1624 // Workflow runs' artifacts are another thing, with their own scope.
1625 let reader = token(&[Scope::ArtifactsRead]);
1626 assert!(!decide(&reader, "list_artifacts", &json!({ "repo": "acme/web" })).allowed);
1627 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 yet1628 }
1629
1630 #[test]
The phone app: Chat in full with phone notifications, plus Code and Customize (#4)1631 fn chat_is_a_persons_own_and_given_only_when_asked_for() {
1632 for scope in [Scope::ChatRead, Scope::ChatWrite] {
1633 assert_eq!(scope.resource(), Resource::Chat);
1634 assert!(!scope.dangerous());
1635 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
1636 }
1637 assert_eq!(Resource::Chat.label(), "Chat");
1638 assert_eq!(Resource::Chat.group(), ResourceGroup::Account);
1639 assert!(Scope::ChatWrite.includes(Scope::ChatRead));
1640 assert!(!Scope::ChatRead.includes(Scope::ChatWrite));
1641 // No preset, and so no OAuth default, gives it.
1642 for preset in Preset::ALL {
1643 let scopes = preset.scopes().unwrap_or_default();
1644 assert!(!scopes.iter().any(|scope| scope.resource() == Resource::Chat), "{}", preset.as_str());
1645 }
1646 assert!(!oauth_default().iter().any(|scope| scope.resource() == Resource::Chat));
1647 // An application that asks for it by name gets it; full access holds it.
1648 assert_eq!(parse_scopes("chat:read chat:write"), vec![Scope::ChatRead, Scope::ChatWrite]);
1649 assert!(offered_scopes().contains(&Scope::ChatWrite));
1650 assert!(everything().contains(&Scope::ChatWrite));
1651 assert!(TokenAccess::full().allows(Scope::ChatWrite));
1652 // Only a person's token may hold it.
1653 let asked = std::collections::BTreeMap::from([("chat".to_owned(), "write".to_owned())]);
1654 assert_eq!(resolve_permissions(&asked, true), Ok(vec![Scope::ChatWrite]));
1655 assert!(resolve_permissions(&asked, false).is_err());
1656 }
1657
1658 #[test]
Merge checks: statuses and check runs on every commit1659 fn checks_are_reported_with_checks_write_which_ci_gets() {
1660 assert_eq!(scope_for("create_check_run"), Some(Scope::ChecksWrite));
1661 assert_eq!(scope_for("create_commit_status"), Some(Scope::ChecksWrite));
1662 assert_eq!(scope_for("list_check_runs_for_ref"), Some(Scope::ChecksRead));
1663 let ci = Preset::Ci.scopes().unwrap();
1664 assert!(ci.contains(&Scope::ChecksWrite));
1665 assert!(!Preset::Agent.scopes().unwrap().contains(&Scope::ChecksWrite));
1666 let reporter = token(&[Scope::ChecksWrite]);
1667 assert!(decide(&reporter, "update_check_run", &json!({ "id": "cr_1" })).allowed);
1668 assert!(decide(&reporter, "rerequest_check_run", &json!({ "id": "cr_1" })).allowed);
1669 // A g1t Actions job runs again as its workflow does.
1670 let refused = decide(&reporter, "rerequest_check_run", &json!({ "id": "job_1" }));
1671 assert!(refused.reason.unwrap().contains("workflows:write"));
1672 }
1673
1674 #[test]
Merge branch 'worktree-agent-a3abfcce648e87dca'1675 fn a_job_token_reaches_its_repository_only() {
1676 let job = TokenAccess {
1677 repo: Some("acme/web".into()),
1678 job: Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false }),
1679 ..token(&[Scope::RepoRead, Scope::IssuesWrite, Scope::IssuesRead, Scope::PullRequestsWrite])
1680 };
1681 assert!(decide(&job, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1682 assert!(decide(&job, "create_issue", &json!({ "repo": "Acme/Web" })).allowed, "names compare without case");
1683 let elsewhere = decide(&job, "create_issue", &json!({ "repo": "acme/api" }));
1684 assert!(!elsewhere.allowed);
1685 assert_eq!(elsewhere.rule, "token:repository");
1686 // Nothing beyond the one repository, a workspace's listing included.
1687 assert!(!decide(&job, "list_repos", &json!({})).allowed);
1688 assert!(decide(&job, "whoami", &json!({})).allowed);
1689 // Its scopes still hold inside it.
1690 assert!(!decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1691 assert!(decide_repo(&job, "acme/web").is_none());
1692 assert!(!decide_repo(&job, "acme/api").unwrap().allowed);
1693 assert!(decide_repo(&token(&[Scope::CodeRead]), "acme/api").is_none(), "other tokens reach what their owner can");
1694 // Opening and approving pull requests is off unless allowed.
1695 assert_eq!(decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).rule, "token:pull-requests");
1696 assert!(!decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "approve" })).allowed);
1697 assert!(decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "request_changes" })).allowed);
1698 let allowed = TokenAccess { job: Some(JobToken { pull_requests: true, ..job.job.clone().unwrap() }), ..job.clone() };
1699 assert!(decide(&allowed, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1700 }
1701
1702 #[test]
Workflow files need workflow_files:write from a token; fine-grained permission table1703 fn workflow_files_need_their_own_scope() {
1704 for path in [".g1t/workflows/ci.yml", ".github/workflows/deploy.yaml", "/.github/workflows/x.yml", ".GitHub/Workflows/ci.yml", ".github/workflows"] {
1705 assert!(is_workflow_file(path), "{path}");
1706 }
1707 for path in ["README.md", ".github/CODEOWNERS", ".github/workflowsx/ci.yml", "docs/.github/workflows/ci.yml", ".g1t/actions/ci.yml"] {
1708 assert!(!is_workflow_file(path), "{path}");
1709 }
1710 let code = token(&[Scope::CodeWrite]);
1711 let refused = decide_workflow_files(Some(&code), ["README.md", ".github/workflows/ci.yml"]).unwrap();
1712 assert_eq!(refused.rule, "token:workflows");
1713 assert!(refused.reason.as_deref().unwrap().contains(".github/workflows/ci.yml"));
1714 assert!(refused.reason.as_deref().unwrap().contains("workflow_files:write"));
1715 assert!(decide_workflow_files(Some(&code), ["README.md"]).is_none());
1716 assert!(decide_workflow_files(Some(&token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite])), [".g1t/workflows/ci.yml"]).is_none());
1717 assert!(decide_workflow_files(Some(&TokenAccess::full()), [".g1t/workflows/ci.yml"]).is_none(), "full access");
1718 assert!(decide_workflow_files(None, [".g1t/workflows/ci.yml"]).is_none(), "a signed-in person");
1719 // A job's token never may, as GITHUB_TOKEN never may.
1720 let job = TokenAccess { job: Some(JobToken::default()), ..TokenAccess::full() };
1721 assert!(decide_workflow_files(Some(&job), [".g1t/workflows/ci.yml"]).unwrap().reason.unwrap().contains("job"));
1722 // Nothing in a preset changes workflow files but full access.
1723 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1724 assert!(!preset.scopes().unwrap().contains(&Scope::WorkflowFilesWrite), "{}", preset.as_str());
1725 }
1726 assert!(!Scope::WorkflowFilesWrite.includes(Scope::WorkflowsWrite) && !Scope::WorkflowsWrite.includes(Scope::WorkflowFilesWrite));
1727 }
1728
1729 #[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 managers1730 fn a_narrowed_token_only_reads_outside_its_workspace() {
1731 let reach = TokenReach { workspace: Some("acme".into()), repositories: RepositorySelection::All, repo_ids: Vec::new() };
1732 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 table1733 assert!(decide(&fine, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1734 assert!(decide(&fine, "create_issue", &json!({ "repo": "Acme/web" })).allowed);
1735 let elsewhere = decide(&fine, "create_issue", &json!({ "repo": "globex/site" }));
1736 assert_eq!(elsewhere.rule, "token:resource-owner");
1737 assert!(elsewhere.reason.unwrap().contains("acme"));
1738 assert!(decide(&fine, "get_issue", &json!({ "repo": "globex/site" })).allowed, "public repositories elsewhere read");
1739 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 managers1740 let mine = TokenAccess { reach: Some(TokenReach::default()), ..token(&[Scope::IssuesWrite]) };
Workflow files need workflow_files:write from a token; fine-grained permission table1741 assert!(decide(&mine, "create_issue", &json!({ "repo": "acme/web" })).reason.unwrap().contains("your account"));
1742 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 managers1743 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 table1744 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 managers1745 let public = TokenReach { repositories: RepositorySelection::Public, ..selected.clone() };
Workflow files need workflow_files:write from a token; fine-grained permission table1746 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 managers1747 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 table1748 assert_eq!(RepositorySelection::parse("public_only"), Some(RepositorySelection::Public));
1749 }
1750
1751 #[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 managers1752 fn permissions_are_scopes_read_per_resource() {
1753 let asked: std::collections::BTreeMap<String, String> =
1754 [("issues", "write"), ("repo", "read"), ("code", "none"), ("packages", "delete")].iter().map(|(a, b)| ((*a).to_owned(), (*b).to_owned())).collect();
1755 let scopes = resolve_permissions(&asked, true).unwrap();
1756 assert_eq!(scopes, vec![Scope::RepoRead, Scope::PackagesDelete, Scope::IssuesWrite]);
1757 let back = permissions_of(&scopes);
1758 assert_eq!(back.get("issues").map(String::as_str), Some("write"));
1759 assert_eq!(back.get("packages").map(String::as_str), Some("delete"));
1760 assert!(!back.contains_key("code"));
1761 // Lower levels held beside a higher one say nothing more.
1762 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 yet1763 // 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 managers1764 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 yet1765 assert_eq!(all.len(), Resource::ALL.into_iter().filter(|resource| resource.offered()).count());
1766 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 managers1767 assert!(all.iter().any(|held| held.includes(scope)), "{scope:?}");
1768 }
1769 }
1770
1771 #[test]
1772 fn permissions_are_checked_by_name_level_and_owner() {
1773 let one = |name: &str, level: &str| -> std::collections::BTreeMap<String, String> { [(name.to_owned(), level.to_owned())].into() };
1774 assert!(resolve_permissions(&one("wiki", "read"), true).unwrap_err().contains("wiki"));
1775 assert!(resolve_permissions(&one("issues", "admin"), true).unwrap_err().contains("read, write"));
1776 assert!(resolve_permissions(&one("workflow_files", "read"), true).is_err(), "workflow files are written only");
1777 assert!(resolve_permissions(&one("notifications", "read"), false).unwrap_err().contains("account"));
1778 assert_eq!(resolve_permissions(&one("notifications", "read"), true).unwrap(), vec![Scope::NotificationsRead]);
1779 assert_eq!(resolve_permissions(&one("agents", "run"), false).unwrap(), vec![Scope::AgentsRun]);
1780 for resource in Resource::ALL {
1781 assert_eq!(Resource::parse(resource.as_str()), Some(resource));
1782 assert!(!resource.scopes().is_empty());
1783 }
1784 }
1785
1786 #[test]
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1787 fn a_legacy_token_can_do_everything() {
1788 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1789 for (operation, _) in OPERATIONS {
1790 assert!(decide(&legacy, operation, &json!({})).allowed, "{operation}");
1791 }
1792 assert_eq!(decide(&legacy, "delete_repo", &json!({})).rule, "token:legacy");
1793 }
1794
1795 #[test]
1796 fn a_missing_scope_is_named() {
1797 let read = token(&[Scope::IssuesRead]);
1798 assert!(decide(&read, "get_issue", &json!({})).allowed);
1799 assert!(decide(&read, "whoami", &json!({})).allowed);
1800 let refused = decide(&read, "create_issue", &json!({}));
1801 assert!(!refused.allowed);
1802 assert_eq!(refused.reason.as_deref(), Some("This access token needs the issues:write scope to use create_issue."));
1803 // An operation the table does not know needs full access.
1804 assert!(!decide(&read, "something_new", &json!({})).allowed);
1805 }
1806
1807 #[test]
1808 fn starting_agents_from_another_operation_needs_agents_run() {
1809 let writer = token(&[Scope::IssuesWrite]);
1810 assert!(decide(&writer, "apply_plan", &json!({})).allowed);
1811 let refused = decide(&writer, "apply_plan", &json!({ "assign": true }));
1812 assert!(refused.reason.unwrap().contains("agents:run"));
1813 let maintainer = token(&[Scope::RepoWrite]);
1814 assert!(decide(&maintainer, "update_repo", &json!({ "description": "x" })).allowed);
1815 assert!(!decide(&maintainer, "update_repo", &json!({ "private": true })).allowed);
1816 }
1817
1818 #[test]
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1819 fn a_workspaces_base_permission_needs_access_admin_too() {
1820 let admin = token(&[Scope::WorkspaceAdmin]);
1821 assert!(decide(&admin, "update_workspace", &json!({ "name": "Acme" })).allowed);
1822 let refused = decide(&admin, "update_workspace", &json!({ "name": "Acme", "base_permission": "read" }));
1823 assert!(refused.reason.unwrap().contains("access:admin"));
1824 let both = token(&[Scope::WorkspaceAdmin, Scope::AccessAdmin]);
1825 assert!(decide(&both, "update_workspace", &json!({ "base_permission": "read" })).allowed);
1826 assert!(!decide(&token(&[Scope::WorkspaceRead]), "update_workspace", &json!({ "name": "Acme" })).allowed);
1827 }
1828
1829 #[test]
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1830 fn delegating_needs_both_agents_and_issues() {
1831 let agents = token(&[Scope::AgentsRun]);
1832 assert!(decide(&agents, "delegate", &json!({})).reason.unwrap().contains("issues:write"));
1833 let both = token(&[Scope::AgentsRun, Scope::IssuesWrite]);
1834 assert!(decide(&both, "delegate", &json!({})).allowed);
1835 }
1836
1837 #[test]
1838 fn git_push_needs_code_write_and_private_reads_need_code_read() {
1839 let reader = token(&[Scope::CodeRead]);
1840 assert!(decide_git(&reader, false, false).allowed);
1841 let refused = decide_git(&reader, true, false);
1842 assert!(!refused.allowed);
1843 assert!(refused.reason.unwrap().contains("code:write"));
1844 let issues = token(&[Scope::IssuesWrite]);
1845 assert!(!decide_git(&issues, false, false).allowed);
1846 assert!(decide_git(&issues, false, true).allowed, "public code needs no scope");
1847 assert!(!decide_git(&issues, true, true).allowed, "pushing to public code still needs code:write");
1848 let writer = token(&[Scope::CodeWrite]);
1849 assert!(decide_git(&writer, true, false).allowed);
1850 assert!(decide_git(&writer, false, false).allowed, "code:write includes code:read");
1851 assert!(decide_git(&TokenAccess::full(), true, false).allowed);
1852 }
1853
1854 #[test]
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1855 fn packages_need_their_own_scopes_and_public_pulls_none() {
1856 let reader = token(&[Scope::PackagesRead]);
1857 assert!(decide_packages(&reader, Level::Read, false).allowed);
1858 assert!(!decide_packages(&reader, Level::Write, false).allowed);
1859 let code = token(&[Scope::CodeWrite]);
1860 assert!(!decide_packages(&code, Level::Read, false).allowed, "code scopes are not package scopes");
1861 assert!(decide_packages(&code, Level::Read, true).allowed, "public packages pull with any token");
1862 let writer = token(&[Scope::PackagesWrite]);
1863 assert!(decide_packages(&writer, Level::Write, false).allowed);
1864 assert!(decide_packages(&writer, Level::Read, false).allowed, "packages:write includes packages:read");
1865 let refused = decide_packages(&writer, Level::Delete, false);
1866 assert!(refused.reason.unwrap().contains("packages:delete"));
1867 assert!(decide_packages(&token(&[Scope::PackagesDelete]), Level::Write, false).allowed);
1868 assert!(Scope::PackagesDelete.dangerous());
1869 // Tokens made before these scopes, and full-access ones, keep working.
1870 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1871 assert!(decide_packages(&legacy, Level::Delete, false).allowed);
1872 assert!(decide_packages(&TokenAccess::full(), Level::Write, false).allowed);
1873 }
1874
1875 #[test]
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1876 fn token_access_travels_as_json() {
1877 let access = token(&[Scope::IssuesRead]);
1878 let wire = serde_json::to_value(&access).unwrap();
1879 assert_eq!(wire["scopes"], json!(["issues:read"]));
1880 assert!(wire.get("resources").is_none());
1881 let back: TokenAccess = serde_json::from_value(wire).unwrap();
1882 assert_eq!(back, access);
1883 let full: TokenAccess = serde_json::from_value(json!({})).unwrap();
1884 assert!(full.is_full());
1885 // A reach written by an older version is ignored: a token reaches
1886 // whatever its owner can.
1887 let older: TokenAccess = serde_json::from_value(json!({
1888 "token_id": "tok_1",
1889 "scopes": ["issues:read"],
1890 "resources": { "kind": "repositories", "repositories": ["acme/rocket"] },
1891 }))
1892 .unwrap();
1893 assert_eq!(older, access);
Merge main into Artifacts Phase 21894 // Using the website is off unless set, and said only when on.
1895 assert!(!access.website);
1896 assert!(wire_of(&access).get("website").is_none());
1897 let website = TokenAccess { website: true, ..access };
1898 assert_eq!(wire_of(&website)["website"], json!(true));
1899 // Never part of full access.
1900 assert!(!TokenAccess::full().website);
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1901 }
1902
Merge main into Artifacts Phase 21903 fn wire_of(access: &TokenAccess) -> serde_json::Value {
1904 serde_json::to_value(access).unwrap()
1905 }
1906
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1907 /// The site's copy of the table, `packages/contracts/src/scopes.ts`,
1908 /// lists the same scopes in the same order, the same operations with
1909 /// the same scopes, and the same presets.
1910 #[test]
1911 fn the_typescript_mirror_has_the_same_table() {
1912 let ts = include_str!("../../../packages/contracts/src/scopes.ts");
1913 let section = |start: &str| {
1914 ts.split_once(start)
1915 .and_then(|(_, rest)| rest.split_once("] as const"))
1916 .map(|(table, _)| table)
1917 .unwrap_or_else(|| panic!("{start} in scopes.ts"))
1918 };
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 yet1919 let names = |table: &str| -> Vec<String> {
1920 section(table)
1921 .lines()
1922 .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope.to_owned()))
1923 .collect()
1924 };
1925 // Offered scopes in `SCOPES`, the rest in `UPCOMING_SCOPES`.
1926 let offered: Vec<String> = Scope::ALL.iter().filter(|scope| scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
1927 let upcoming: Vec<String> = Scope::ALL.iter().filter(|scope| !scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
1928 assert_eq!(names("export const SCOPES = ["), offered);
Merge main into Artifacts Phase 21929 assert_eq!(names("export const UPCOMING_SCOPES"), upcoming);
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1930 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
1931 .lines()
1932 .filter_map(|line| {
1933 let mut quoted = line.split('"').skip(1).step_by(2);
1934 Some((quoted.next()?.to_owned(), quoted.next()?.to_owned()))
1935 })
1936 .collect();
1937 let expected: Vec<(String, String)> = OPERATIONS
1938 .iter()
1939 .map(|(name, scope)| ((*name).to_owned(), scope.as_str().to_owned()))
1940 .collect();
1941 assert_eq!(operations, expected);
1942 for preset in Preset::ALL {
1943 let list = section(&format!("{}: [", preset.as_str()));
1944 let mirrored: Vec<&str> = list
1945 .split(',')
1946 .map(|item| item.trim().trim_matches('"'))
1947 .filter(|item| !item.is_empty())
1948 .collect();
1949 let expected: Vec<&str> = preset
1950 .scopes()
1951 .map(|scopes| scopes.iter().map(|scope| scope.as_str()).collect())
1952 .unwrap_or_else(|| vec!["*"]);
1953 assert_eq!(mirrored, expected, "{}", preset.as_str());
1954 }
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 yet1955 // Each resource with its group, in the same order: offered ones in
1956 // `SCOPE_RESOURCES`, the rest in `UPCOMING_RESOURCES`.
1957 for (table, offered) in [("export const SCOPE_RESOURCES", true), ("export const UPCOMING_RESOURCES", false)] {
1958 let resources = ts
1959 .split_once(table)
1960 .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 managers1961];"))
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 yet1962 .map(|(table, _)| table)
1963 .unwrap_or_else(|| panic!("{table} in scopes.ts"));
1964 let rows: Vec<&str> = resources.lines().filter(|line| line.trim_start().starts_with("{ resource:")).collect();
1965 let expected: Vec<Resource> = Resource::ALL.into_iter().filter(|resource| resource.offered() == offered).collect();
1966 assert_eq!(rows.len(), expected.len(), "{table}");
1967 for (row, resource) in rows.iter().zip(expected) {
1968 assert!(row.contains(&format!("resource: \"{}\"", resource.as_str())), "{row}");
1969 assert!(row.contains(&format!("group: \"{}\"", resource.group().as_str())), "{row}");
1970 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1971 }
Deploys as code: a manifest of every Worker, a deploy tool that ships only what changed in parallel stages, and a g1t Actions workflow1972 }
1973}

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