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

Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1//! Run credentials: the least-privilege tokens a sandbox works with.
2//!
3//! Every sandbox run gets its own tokens, bound to the run, its repository
4//! (and the pull request's fork), what that kind of run needs to do, and an
5//! expiry no later than the run's timeout. Each carries a composite
6//! identity: an agent acting on behalf of the person who started the work.
7//! What it may do is the intersection of the two: the run's scope, and what
8//! that person may do right now.
9//!
10//! The policy lives here, as pure functions, so that identity (which mints
11//! the tokens), the API (which serves REST and MCP) and repos (which serves
12//! git) all enforce the same rules, and so the rules can be tested.
13
14use serde::{Deserialize, Serialize};
15
16use crate::identity::AgentScope;
17use crate::repos::RepoPath;
18use crate::access::{BasePermission, RepoGrant, RepoRole};
19use crate::{Membership, PrincipalKind, Role, User};
20
21/// What a run does, as far as its credentials are concerned. The same names
22/// as [`crate::agents::RunKind`], plus `deploy`, a build of one commit.
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum RunCredentialKind {
26 Implement,
27 Revise,
28 Review,
29 Answer,
30 Update,
31 Plan,
32 Checks,
33 Queue,
34 Mergecheck,
35 Deploy,
36 /// A security update: raising one package's version in its lockfiles
37 /// and pushing that to a branch of its own. Not an agent.
38 Bump,
39}
40
41impl RunCredentialKind {
42 pub const ALL: [RunCredentialKind; 11] = [
43 RunCredentialKind::Implement,
44 RunCredentialKind::Revise,
45 RunCredentialKind::Review,
46 RunCredentialKind::Answer,
47 RunCredentialKind::Update,
48 RunCredentialKind::Plan,
49 RunCredentialKind::Checks,
50 RunCredentialKind::Queue,
51 RunCredentialKind::Mergecheck,
52 RunCredentialKind::Deploy,
53 RunCredentialKind::Bump,
54 ];
55
56 pub fn as_str(self) -> &'static str {
57 match self {
58 RunCredentialKind::Implement => "implement",
59 RunCredentialKind::Revise => "revise",
60 RunCredentialKind::Review => "review",
61 RunCredentialKind::Answer => "answer",
62 RunCredentialKind::Update => "update",
63 RunCredentialKind::Plan => "plan",
64 RunCredentialKind::Checks => "checks",
65 RunCredentialKind::Queue => "queue",
66 RunCredentialKind::Mergecheck => "mergecheck",
67 RunCredentialKind::Deploy => "deploy",
68 RunCredentialKind::Bump => "bump",
69 }
70 }
71
72 /// Whether the run works on one pull request, whose session and
73 /// readiness it reports.
74 fn works_on_a_pull(self) -> bool {
75 matches!(
76 self,
77 RunCredentialKind::Implement
78 | RunCredentialKind::Revise
79 | RunCredentialKind::Answer
80 | RunCredentialKind::Update
81 )
82 }
83}
84
85/// Which part of a sandbox a credential is for.
86#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum CredentialUse {
89 /// g1t's runner: cloning, pushing the result, recording the session.
90 /// It acts as the person downstream, so that what it pushes and records
91 /// is theirs, within the run's scope.
92 Runner,
93 /// The agent's own tools, over MCP. It acts as the agent.
94 Tools,
95}
96
97impl CredentialUse {
98 pub fn as_str(self) -> &'static str {
99 match self {
100 CredentialUse::Runner => "runner",
101 CredentialUse::Tools => "tools",
102 }
103 }
104}
105
106/// A repository a run may push to, and the one branch, if only one.
107#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
108pub struct GitGrant {
109 pub repo: RepoPath,
110 /// Null: any branch. A pull request's fork is its own repository, so
111 /// the whole of it is the pull request's.
112 #[serde(default)]
113 pub branch: Option<String>,
114}
115
116/// What binds an agent's token to one run. Absent on agent tokens made
117/// before run credentials, which keep working for the API only.
118#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct RunBinding {
121 pub kind: RunCredentialKind,
122 #[serde(rename = "use")]
123 pub usage: CredentialUse,
124 /// The agent run, once the sandbox has recorded it.
125 #[serde(default)]
126 pub run_id: Option<String>,
127 /// The pull request the run works on, for the kinds that work on one.
128 #[serde(default)]
129 pub number: Option<u32>,
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent130 /// The agent's name, such as `g1t`.
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays131 pub agent: String,
132 /// Repositories it may clone and fetch, besides those it may push to.
133 #[serde(default)]
134 pub read: Vec<RepoPath>,
135 /// Where it may push.
136 #[serde(default)]
137 pub push: Vec<GitGrant>,
138 /// g1t's own run (a security update, an agent g1t put on one): the
139 /// credential belongs to the workspace, and acts on behalf of g1t
140 /// (`system::ID`), so what it does is g1t's, and the pull request g1t
141 /// opened, and its working copy, are its own.
142 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
143 pub system: bool,
144}
145
146/// A person, by id and name.
147#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
148pub struct Principal {
149 pub id: String,
150 pub username: String,
151}
152
g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights153impl From<&User> for Principal {
154 fn from(user: &User) -> Self {
155 Principal {
156 id: user.id.clone(),
157 username: user.username.clone(),
158 }
159 }
160}
161
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays162/// Set on a [`User`] resolved from an agent's token: the composite
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent163/// identity, "g1t on behalf of syntaqx", and what it may do.
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays164#[derive(Clone, Debug, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166pub struct Acting {
167 /// The token's id, as audit entries name it.
168 pub credential_id: String,
169 pub agent: String,
170 pub on_behalf_of: Principal,
171 pub scope: AgentScope,
172}
173
174impl Acting {
175 pub fn run(&self) -> Option<&RunBinding> {
176 self.scope.run.as_ref()
177 }
178}
179
180/// `create_run_credential`: a token for one sandbox run. It acts as
181/// `agent` on behalf of `on_behalf_of`, can do only what `kind` and `usage`
182/// allow in `repo`, and expires after `ttl_seconds`, which should be the
183/// run's timeout. Returns `CreatedAccessToken`.
184#[derive(Clone, Debug, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub struct CreateRunCredentialArgs {
187 pub on_behalf_of: User,
188 pub repo: RepoPath,
189 pub kind: RunCredentialKind,
190 #[serde(rename = "use")]
191 pub usage: CredentialUse,
192 #[serde(default)]
193 pub number: Option<u32>,
194 #[serde(default)]
195 pub read: Vec<RepoPath>,
196 #[serde(default)]
197 pub push: Vec<GitGrant>,
198 pub ttl_seconds: u64,
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent199 /// Defaults to `g1t`.
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays200 #[serde(default)]
201 pub agent: Option<String>,
202}
203
204/// `bind_run_credentials`: ties tokens, named by the SHA-256 of their
205/// text in hex, to the agent run their sandbox recorded. Returns how many.
206#[derive(Clone, Debug, Serialize, Deserialize)]
207#[serde(rename_all = "camelCase")]
208pub struct BindRunCredentialsArgs {
209 pub token_hashes: Vec<String>,
210 pub run_id: String,
211}
212
213/// `revoke_run_credentials`: ends tokens when their sandbox stops, by hash
214/// or by run. Only run credentials are touched, never a token a person
215/// made. Returns how many.
216#[derive(Clone, Debug, Default, Serialize, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub struct RevokeRunCredentialsArgs {
219 #[serde(default)]
220 pub token_hashes: Vec<String>,
221 #[serde(default)]
222 pub run_id: Option<String>,
223}
224
225// --- Policy --------------------------------------------------------------
226
227/// Operations that only read.
228pub const READ_OPERATIONS: &[&str] = &[
229 "whoami",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit230 "get_usage",
231 "get_budget",
232 "get_ai_credit",
233 "list_invoices",
234 "get_billing_details",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays235 "list_repos",
236 "get_repo",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97237 "list_projects",
238 "get_project",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays239 "list_deleted_repos",
240 "list_collaborators",
241 "get_collaborator_permission",
242 "list_repo_invitations",
243 "list_my_repo_invitations",
244 "list_outside_collaborators",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar245 "list_teams",
246 "get_team",
247 "list_team_members",
248 "list_child_teams",
249 "list_team_repos",
250 "list_user_teams",
251 "get_codeowners_errors",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays252 "get_repo_settings",
253 "list_check_names",
254 "get_merge_queue",
255 "recall",
256 "list_issues",
257 "get_issue",
258 "get_plan",
259 "list_labels",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar260 "list_issue_labels",
261 "list_milestones",
262 "get_milestone",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays263 "list_pull_requests",
264 "get_pull_request",
265 "read_session",
266 "get_pull_request_changes",
267 "list_events",
268 "get_context",
269 "search_context",
270 "get_entity",
271 "search",
272 "list_workflows",
273 "list_workflow_runs",
274 "get_workflow_run",
275 "get_job_logs",
Merge branch 'worktree-agent-a3abfcce648e87dca'276 "get_pending_deployments",
277 "get_workflow_permissions",
278 "get_fork_pr_approval",
Merge checks: statuses and check runs on every commit279 "list_commit_statuses",
280 "get_combined_status",
281 "list_check_runs_for_ref",
282 "get_check_run",
283 "list_check_run_annotations",
284 "list_check_suites_for_ref",
285 "get_check_suite",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays286 "list_integrations",
287 "get_model_routes",
288 "list_webhooks",
289 "list_webhook_deliveries",
290 "list_actions_secrets",
291 "list_actions_variables",
292 "list_security_alerts",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar293 "list_secret_scanning_alerts",
294 "get_secret_scanning_alert",
295 "list_secret_scanning_locations",
296 "list_bypass_requests",
297 "list_custom_patterns",
298 "list_code_scanning_alerts",
299 "get_code_scanning_alert",
300 "list_code_scanning_analyses",
301 "get_sarif_upload",
302 "list_vulnerability_alerts",
303 "get_vulnerability_alert",
304 "get_dependency_graph",
305 "get_sbom",
306 "compare_dependencies",
307 "get_security_settings",
308 "get_workspace_security_settings",
309 "get_security_overview",
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9310 "list_security_advisories",
311 "get_security_advisory",
312 "list_vulnerability_reports",
API: notifications over REST and MCP, with notifications scopes313 "list_notifications",
314 "get_notification_thread",
315 "get_thread_subscription",
316 "get_repo_subscription",
317 "list_watched_repos",
API: pinned projects over REST and MCP318 "list_pinned_projects",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays319];
320
321/// What no agent's token may ever do, whatever its scope says: workspaces,
322/// repositories' settings, members, tokens, billing, integrations,
323/// webhooks, secrets, workflows' controls, merging, and putting more agents
324/// to work.
325pub const NEVER: &[&str] = &[
Usage, Billing settings and prepaid AI credit; fixes from the UX audit326 // Billing is people's: agents never spend or change it.
327 "set_budget",
328 "buy_ai_credit",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays329 "create_workspace",
330 "delete_workspace",
331 "update_workspace",
Merge main (membership, two-factor, GitHub repo roles) into tokens332 // So is who belongs to a workspace and who owns it.
333 "list_members",
334 "update_member",
335 "remove_member",
336 "transfer_ownership",
337 "leave_workspace",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays338 "transfer_repo",
339 "create_repo",
340 "update_repo",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97341 "update_project",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays342 "delete_repo",
343 "list_deleted_repos",
344 "restore_repo",
345 "purge_repo",
346 "rename_repo",
347 "archive_repo",
348 "unarchive_repo",
349 "set_repo_visibility",
350 "rename_branch",
The Branches page does things to branches now. It has tabs (Overview, Yours, Active, Stale, All, kept in the address as ?tab=) and search; Overview shows the default branch, then the first five of your branches, the active ones and the stale ones, each linking to the rest. Each branch is a row with its name in a code chip with a copy button, who made its latest commit and when, that commit's checks, behind and ahead counts with bars drawn against the largest shown, and its pull request (the open one, else the newest merged or closed) with its state; on a phone the columns fold under the name. Yours is branches whose latest commit is the viewer's; stale is no commit in 90 days.351 "delete_branch",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays352 "update_repo_settings",
353 "merge_pull_request",
354 "assign_issue",
355 "plan_work",
356 "apply_plan",
357 "import_issue",
358 "list_integrations",
359 "connect_integration",
AI Gateway: OpenAI's format, open models, and your own providers360 "update_integration",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays361 "disconnect_integration",
362 "test_integration",
363 "get_model_routes",
364 "set_model_routes",
365 "list_webhooks",
366 "create_webhook",
367 "update_webhook",
368 "delete_webhook",
369 "ping_webhook",
370 "list_webhook_deliveries",
371 "redeliver_webhook",
372 "dispatch_workflow",
373 "cancel_workflow_run",
374 "rerun_workflow_run",
375 "update_workflow",
Merge branch 'worktree-agent-a3abfcce648e87dca'376 // Nor lets runs or deployments through, or changes what holds them.
377 "approve_workflow_run",
378 "review_pending_deployments",
379 "update_environment",
380 "delete_environment",
381 "set_workflow_permissions",
382 "set_fork_pr_approval",
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts383 "set_actions_access",
Merge branch 'worktree-agent-a3abfcce648e87dca'384 "create_repository_dispatch",
385 "set_workspace_workflow_permissions",
Merge checks: statuses and check runs on every commit386 // An agent never reports checks on its own work, nor asks for them
387 // to run again: what checks say is the integrations' to say.
388 "create_commit_status",
389 "create_check_run",
390 "update_check_run",
391 "rerequest_check_run",
392 "rerequest_check_suite",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays393 "list_actions_secrets",
394 "set_actions_secret",
395 "delete_actions_secret",
396 "list_actions_variables",
397 "set_actions_variable",
398 "delete_actions_variable",
399 "list_collaborators",
400 "get_collaborator_permission",
401 "add_collaborator",
402 "update_collaborator",
403 "remove_collaborator",
404 "list_repo_invitations",
405 "revoke_repo_invitation",
406 "list_my_repo_invitations",
407 "accept_repo_invitation",
408 "decline_repo_invitation",
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)409 // Nor answers a workspace invitation: only the person it is for does.
410 "accept_invitation",
411 "decline_invitation",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays412 "set_base_permission",
413 "list_outside_collaborators",
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca414 // Deploy keys, which let a machine into a repository.
415 "list_deploy_keys",
416 "get_deploy_key",
417 "create_deploy_key",
418 "delete_deploy_key",
Merge branch 'mirroring' into artifacts-mode419 // Moving a repository to g1t for good, and a remote's token and
420 // levers, are a person's to decide.
421 "move_mirror_to_g1t",
422 "add_mirror_remote",
423 "update_mirror_remote",
424 "remove_mirror_remote",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar425 // Teams: who is in which, and what they reach, is for people.
426 "create_team",
427 "update_team",
428 "delete_team",
429 "set_team_member",
430 "remove_team_member",
431 "set_team_repo",
432 "remove_team_repo",
433 "set_team_review_assignment",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays434 // Dismissing a secret lets it through push protection.
435 "dismiss_security_alert",
436 "reopen_security_alert",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar437 // Nor any other decision about security: closing or reopening an
438 // alert, pushing past push protection or deciding who may, changing
439 // what is looked for or when checks fail, or putting more agents to
440 // work. An agent fixes what it finds in its own pull request.
441 "update_secret_scanning_alert",
442 "bypass_push_protection",
443 "review_bypass_request",
444 "create_custom_pattern",
445 "update_custom_pattern",
446 "delete_custom_pattern",
447 "update_code_scanning_alert",
448 "update_vulnerability_alert",
449 "fix_security_alert",
450 "update_security_settings",
451 "update_workspace_security_settings",
API: notifications over REST and MCP, with notifications scopes452 // A person's own inbox: g1t's agents act as g1t, which has none.
453 "list_notifications",
454 "get_notification_thread",
455 "mark_notifications_read",
456 "mark_thread_read",
457 "mark_thread_done",
458 "save_thread",
459 "snooze_thread",
460 "get_thread_subscription",
461 "set_thread_subscription",
462 "delete_thread_subscription",
463 "get_repo_subscription",
464 "set_repo_subscription",
465 "delete_repo_subscription",
466 "list_watched_repos",
API: pinned projects over REST and MCP467 // Pins are a person's own, as the inbox is.
468 "list_pinned_projects",
469 "pin_project",
470 "unpin_project",
471 "reorder_pinned_projects",
Merge packages: roles, Actions access, source label, soft delete, API472 // Deleting packages and deciding who may use them is for people: an
473 // agent publishes its repository's packages and never deletes them.
474 "update_package",
475 "link_package",
476 "unlink_package",
477 "set_package_access",
478 "remove_package_access",
479 "set_package_actions_access",
480 "remove_package_actions_access",
481 "delete_package",
482 "restore_package",
483 "delete_package_version",
484 "restore_package_version",
Merge main into Artifacts Phase 2485 // Sharing an artifact and deleting one for good are for people: an
486 // agent shares only through the agents service, with the people
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.487 // already in its conversation.
Merge main into Artifacts Phase 2488 "set_workspace_artifact_access",
489 "purge_workspace_artifact",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays490];
491
492/// Reading what an agent needs to know about its repository.
493const TOOLS_READ: &[&str] = &[
494 "get_repo",
495 "list_issues",
496 "get_issue",
497 "list_labels",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar498 "list_issue_labels",
499 "list_milestones",
500 "get_milestone",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays501 "list_pull_requests",
502 "get_pull_request",
503 "get_pull_request_changes",
504 "read_session",
505 "get_merge_queue",
506 "list_events",
507 "recall",
508 "search_context",
509 "get_entity",
510 "search",
511 "list_workflows",
512 "list_workflow_runs",
513 "get_workflow_run",
514 "get_job_logs",
Merge branch 'worktree-agent-a3abfcce648e87dca'515 "get_pending_deployments",
516 "get_workflow_permissions",
517 "get_fork_pr_approval",
Merge checks: statuses and check runs on every commit518 "list_commit_statuses",
519 "get_combined_status",
520 "list_check_runs_for_ref",
521 "get_check_run",
522 "list_check_run_annotations",
523 "list_check_suites_for_ref",
524 "get_check_suite",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays525];
526
527pub fn is_read(operation: &str) -> bool {
528 READ_OPERATIONS.contains(&operation)
529}
530
531/// The API and MCP operations a run of `kind` may use with a credential
532/// for `usage`. Git is separate: see [`decide_git`].
533pub fn operations_for(kind: RunCredentialKind, usage: CredentialUse) -> Vec<&'static str> {
534 use RunCredentialKind as K;
535 let mut operations: Vec<&'static str> = Vec::new();
536 match usage {
537 CredentialUse::Runner => {
538 if kind.works_on_a_pull() {
539 operations.extend(["get_repo", "get_pull_request", "record_session"]);
540 }
541 if kind == K::Implement {
542 operations.push("mark_pull_request_ready");
543 }
544 }
545 CredentialUse::Tools => match kind {
546 K::Implement | K::Revise | K::Answer => {
547 operations.extend(TOOLS_READ.iter().copied());
548 operations.extend([
549 "create_issue",
550 "add_comment",
551 "take_messages",
552 "remember",
553 "message_agent",
554 "answer_message",
555 "get_context",
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9556 "customize_bundle",
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays557 ]);
558 }
559 K::Review => {
560 operations.extend(TOOLS_READ.iter().copied());
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9561 operations.extend(["add_comment", "review_pull_request", "get_context", "customize_bundle"]);
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays562 }
563 K::Plan => {
564 operations.extend(TOOLS_READ.iter().copied());
Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9565 operations.extend(["create_issue", "get_context", "customize_bundle"]);
566 }
567 K::Update => {
568 operations.extend(TOOLS_READ.iter().copied());
569 operations.push("customize_bundle");
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays570 }
571 K::Checks | K::Queue | K::Mergecheck | K::Deploy | K::Bump => {}
572 },
573 }
574 operations
575}
576
577/// What a run's credential may do, in the scope vocabulary that access
578/// tokens use (see [`crate::scopes`]): the scopes of its operations, and
579/// for a runner, git's. Its operations, its repository and its run still
580/// bound it more tightly than these scopes say.
581pub fn run_scopes(kind: RunCredentialKind, usage: CredentialUse) -> Vec<crate::scopes::Scope> {
582 use crate::scopes::{Scope, normalize, scope_for};
583 let mut scopes: Vec<Scope> = operations_for(kind, usage)
584 .into_iter()
585 .filter_map(scope_for)
586 .collect();
587 if usage == CredentialUse::Runner {
588 scopes.push(Scope::CodeRead);
589 if matches!(
590 kind,
591 RunCredentialKind::Implement
592 | RunCredentialKind::Revise
593 | RunCredentialKind::Answer
594 | RunCredentialKind::Update
595 | RunCredentialKind::Bump
596 ) {
597 scopes.push(Scope::CodeWrite);
598 }
599 }
600 normalize(&mut scopes);
601 scopes
602}
603
604/// Operations that change a pull request, which a runner may do only to
605/// the pull request its run works on.
606const PULL_WRITES: &[&str] = &["record_session", "mark_pull_request_ready"];
607
608/// Whether something was allowed, and the rule that decided it.
609#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
610pub struct Decision {
611 pub allowed: bool,
612 /// A short, stable name: `run:implement/tools`, `never`,
613 /// `scope:repository` and so on. Shown in the audit log.
614 pub rule: String,
615 /// Why it was refused, for the caller.
616 #[serde(default, skip_serializing_if = "Option::is_none")]
617 pub reason: Option<String>,
618}
619
620impl Decision {
621 pub fn allow(rule: impl Into<String>) -> Self {
622 Decision {
623 allowed: true,
624 rule: rule.into(),
625 reason: None,
626 }
627 }
628
629 pub fn deny(rule: impl Into<String>, reason: impl Into<String>) -> Self {
630 Decision {
631 allowed: false,
632 rule: rule.into(),
633 reason: Some(reason.into()),
634 }
635 }
636}
637
638fn same_repo(a: &RepoPath, b: &RepoPath) -> bool {
639 a.namespace.eq_ignore_ascii_case(&b.namespace) && a.name.eq_ignore_ascii_case(&b.name)
640}
641
642fn scope_rule(scope: &AgentScope) -> String {
643 match &scope.run {
644 Some(run) => format!("run:{}/{}", run.kind.as_str(), run.usage.as_str()),
645 None => "agent-token".to_owned(),
646 }
647}
648
649/// Whether `user`, resolved from an agent's token with `scope`, may use
650/// `operation`. `repo` is the repository the call names, if any, and
651/// `needs_repo` whether the operation is about one; `number` the issue or
652/// pull request it names.
653pub fn decide_operation(
654 user: &User,
655 scope: &AgentScope,
656 operation: &str,
657 repo: Option<&RepoPath>,
658 needs_repo: bool,
659 number: Option<u32>,
660) -> Decision {
661 let who = "A g1t agent's token";
662 if NEVER.contains(&operation) {
663 return Decision::deny(
664 "never",
665 format!(
666 "{who} can never use {operation}: settings, members, tokens, billing, integrations, webhooks, secrets and merging are for people."
667 ),
668 );
669 }
670 if !scope.operations.iter().any(|name| name == operation) {
671 return Decision::deny(
672 "scope:operation",
673 format!("{who} for this run cannot use {operation}."),
674 );
675 }
676 if needs_repo && !repo.is_some_and(|asked| same_repo(asked, &scope.repo)) {
677 return Decision::deny(
678 "scope:repository",
679 format!(
680 "{who} works in {}/{} only.",
681 scope.repo.namespace, scope.repo.name
682 ),
683 );
684 }
685 // The intersection: the person it acts for must still be able to work
686 // in the repository's workspace, as a member or with a role on its
687 // repositories. What it may do in the repository itself is their
688 // role there, which services check (`access::can`).
689 if !crate::access::has_access_in(user, &scope.repo.namespace) {
690 return Decision::deny(
691 "on-behalf-of:membership",
692 format!(
693 "The person this agent works for is no longer a member of {}.",
694 scope.repo.namespace
695 ),
696 );
697 }
698 if let Some(run) = &scope.run
699 && run.usage == CredentialUse::Runner
700 && PULL_WRITES.contains(&operation)
701 && run.number.is_some()
702 && number != run.number
703 {
704 return Decision::deny(
705 "scope:pull",
706 format!(
707 "{who} can change pull request #{} only.",
708 run.number.unwrap_or_default()
709 ),
710 );
711 }
712 Decision::allow(scope_rule(scope))
713}
714
715/// Whether a run credential may clone or fetch (`write` false), or push to
716/// (`write` true), the repository at `repo`.
717pub fn decide_git(scope: &AgentScope, repo: &RepoPath, write: bool) -> Decision {
718 let Some(run) = scope
719 .run
720 .as_ref()
721 .filter(|run| run.usage == CredentialUse::Runner)
722 else {
723 return Decision::deny(
724 "git:not-a-run",
725 "A g1t agent's tools token cannot be used with git.",
726 );
727 };
728 let pushable = run.push.iter().any(|grant| same_repo(&grant.repo, repo));
729 if write {
730 return if pushable {
731 Decision::allow(format!("{}:push", scope_rule(scope)))
732 } else {
733 Decision::deny(
734 "git:push",
735 format!(
736 "A {} run cannot push to {}/{}.",
737 run.kind.as_str(),
738 repo.namespace,
739 repo.name
740 ),
741 )
742 };
743 }
744 let readable = pushable
745 || same_repo(&scope.repo, repo)
746 || run.read.iter().any(|path| same_repo(path, repo));
747 if readable {
748 Decision::allow(format!("{}:read", scope_rule(scope)))
749 } else {
750 Decision::deny(
751 "git:read",
752 format!(
753 "A {} run cannot read {}/{}.",
754 run.kind.as_str(),
755 repo.namespace,
756 repo.name
757 ),
758 )
759 }
760}
761
762/// Whether a push to `repo` is limited to certain branches, so that the
763/// refs it moves have to be read and checked with [`decide_refs`].
764pub fn limits_branches(scope: &AgentScope, repo: &RepoPath) -> bool {
765 scope
766 .run
767 .iter()
768 .flat_map(|run| run.push.iter())
769 .any(|grant| same_repo(&grant.repo, repo) && grant.branch.is_some())
770}
771
772/// Whether a push to `repo` may move `refs` (full refs, such as
773/// `refs/heads/main`). Tags are never a run's to move.
774pub fn decide_refs(scope: &AgentScope, repo: &RepoPath, refs: &[String]) -> Decision {
775 let repo_decision = decide_git(scope, repo, true);
776 if !repo_decision.allowed {
777 return repo_decision;
778 }
779 let grants: Vec<&GitGrant> = scope
780 .run
781 .iter()
782 .flat_map(|run| run.push.iter())
783 .filter(|grant| same_repo(&grant.repo, repo))
784 .collect();
785 for git_ref in refs {
786 let Some(branch) = git_ref.strip_prefix("refs/heads/") else {
787 return Decision::deny("git:ref", format!("A run cannot push {git_ref}."));
788 };
789 let allowed = grants
790 .iter()
791 .any(|grant| grant.branch.as_deref().is_none_or(|only| only == branch));
792 if !allowed {
793 return Decision::deny(
794 "git:ref",
795 format!(
796 "A run cannot push to {branch} in {}/{}.",
797 repo.namespace, repo.name
798 ),
799 );
800 }
801 }
802 repo_decision
803}
804
805/// The most an agent may be on a repository, whoever it works for: it
806/// can push, merge and run, never change settings or who has access.
807pub const AGENT_CEILING: RepoRole = RepoRole::Write;
808
809/// The memberships an agent working for `person` has: the run's
810/// workspace, as a member, only if the person is in it now, with the
811/// person's role on its repositories (an owner's Admin included) cut down
812/// to [`AGENT_CEILING`].
813pub fn intersect(person: &[Membership], namespace: &str) -> Vec<Membership> {
814 let namespace = namespace.to_lowercase();
815 person
816 .iter()
817 .filter(|membership| membership.slug == namespace)
818 .map(|membership| {
819 let base = match membership.role {
820 Role::Owner => BasePermission::Admin,
821 Role::Member => membership.base_permission.unwrap_or_default(),
822 };
823 Membership {
824 role: Role::Member,
825 base_permission: Some(match base {
826 BasePermission::Admin => BasePermission::Write,
827 base => base,
828 }),
Merge main (membership, two-factor, GitHub repo roles) into tokens829 // Billing and security manager are the person's, never
830 // their agent's.
831 org_roles: Vec::new(),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays832 ..membership.clone()
833 }
834 })
835 .collect()
836}
837
838/// The repository grants an agent working for `person` has: those in the
839/// run's workspace, each cut down to [`AGENT_CEILING`].
840pub fn intersect_grants(person: &[RepoGrant], namespace: &str) -> Vec<RepoGrant> {
841 let namespace = namespace.to_lowercase();
842 person
843 .iter()
844 .filter(|grant| grant.workspace == namespace)
845 .map(|grant| RepoGrant {
846 role: grant.role.min(AGENT_CEILING),
847 ..grant.clone()
848 })
849 .collect()
850}
851
852/// Who a runner's credential acts as downstream: the person, with only the
853/// agent's (already intersected) memberships. `None` for anything else.
854pub fn as_person(user: &User) -> Option<User> {
855 let acting = user.acting.as_ref()?;
856 if user.kind != PrincipalKind::Agent {
857 return None;
858 }
859 let run = acting.run()?;
860 if run.usage != CredentialUse::Runner {
861 return None;
862 }
863 Some(User {
864 id: acting.on_behalf_of.id.clone(),
865 username: acting.on_behalf_of.username.clone(),
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers866 display_username: None,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays867 kind: PrincipalKind::User,
868 verified: user.verified,
869 workspaces: user.workspaces.clone(),
870 avatar: None,
871 acting: None,
872 grants: user.grants.clone(),
873 token: None,
Merge main (membership, two-factor, GitHub repo roles) into tokens874 held: Vec::new(),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays875 })
876}
877
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent878/// How an actor is described: "g1t on behalf of syntaqx".
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays879pub fn describe(user: &User) -> String {
880 match &user.acting {
881 Some(acting) => format!(
882 "{} on behalf of {}",
883 acting.agent, acting.on_behalf_of.username
884 ),
885 None => user.username.clone(),
886 }
887}
888
889#[cfg(test)]
890mod tests {
891 use super::*;
892
893 #[test]
894 fn a_run_s_scopes_are_never_admin() {
895 for kind in RunCredentialKind::ALL {
896 for usage in [CredentialUse::Runner, CredentialUse::Tools] {
897 let scopes = run_scopes(kind, usage);
898 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{kind:?} {usage:?}: {scopes:?}");
899 }
900 }
901 let review = run_scopes(RunCredentialKind::Review, CredentialUse::Tools);
902 assert!(review.contains(&crate::scopes::Scope::PullRequestsWrite));
903 assert!(!review.contains(&crate::scopes::Scope::CodeWrite));
904 }
905
906 #[test]
907 fn agents_can_search_the_context_hub() {
908 for kind in [RunCredentialKind::Implement, RunCredentialKind::Review, RunCredentialKind::Plan] {
909 let tools = operations_for(kind, CredentialUse::Tools);
910 assert!(tools.contains(&"search_context") && tools.contains(&"get_entity"));
911 }
912 assert!(is_read("search_context") && is_read("get_entity"));
913 }
914
915 #[test]
916 fn agents_can_search_all_of_g1t() {
917 // Site-wide search only reads: every run that reads its repository
918 // may use it, and nothing that never reads gets it.
919 assert!(is_read("search"));
920 assert!(!NEVER.contains(&"search"));
921 for kind in [
922 RunCredentialKind::Implement,
923 RunCredentialKind::Revise,
924 RunCredentialKind::Answer,
925 RunCredentialKind::Review,
926 RunCredentialKind::Plan,
927 RunCredentialKind::Update,
928 ] {
929 let tools = operations_for(kind, CredentialUse::Tools);
930 assert!(tools.contains(&"search"), "{kind:?} should search");
931 // The context hub's search stays its own tool beside it.
932 assert!(tools.contains(&"search_context"), "{kind:?} keeps search_context");
933 }
934 for kind in [RunCredentialKind::Checks, RunCredentialKind::Queue, RunCredentialKind::Mergecheck, RunCredentialKind::Deploy, RunCredentialKind::Bump] {
935 assert!(!operations_for(kind, CredentialUse::Tools).contains(&"search"));
936 }
937 assert!(!operations_for(RunCredentialKind::Implement, CredentialUse::Runner).contains(&"search"));
938 }
939
940 fn path(namespace: &str, name: &str) -> RepoPath {
941 RepoPath {
942 namespace: namespace.to_owned(),
943 name: name.to_owned(),
944 }
945 }
946
947 fn scope(kind: RunCredentialKind, usage: CredentialUse) -> AgentScope {
948 AgentScope {
949 repo: path("acme", "rocket"),
950 operations: operations_for(kind, usage)
951 .into_iter()
952 .map(str::to_owned)
953 .collect(),
954 run: Some(RunBinding {
955 kind,
956 usage,
957 run_id: Some("run_1".to_owned()),
958 number: Some(7),
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent959 agent: "g1t".to_owned(),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays960 system: false,
961 read: vec![path("acme", "rocket")],
962 push: match kind {
963 RunCredentialKind::Implement
964 | RunCredentialKind::Revise
965 | RunCredentialKind::Answer => vec![GitGrant {
966 repo: path("pulls", "pul_7"),
967 branch: None,
968 }],
969 RunCredentialKind::Update => vec![GitGrant {
970 repo: path("acme", "rocket"),
971 branch: Some("fix-login".to_owned()),
972 }],
973 _ => vec![],
974 },
975 }),
976 }
977 }
978
979 fn agent(member_of: &[&str], scope: AgentScope) -> User {
980 User {
981 id: "usr_g1t_agent".to_owned(),
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent982 username: "g1t".to_owned(),
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers983 display_username: None,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays984 kind: PrincipalKind::Agent,
985 verified: true,
986 workspaces: member_of
987 .iter()
988 .map(|slug| Membership::member(*slug))
989 .collect(),
990 avatar: None,
991 grants: Vec::new(),
992 token: None,
Merge main (membership, two-factor, GitHub repo roles) into tokens993 held: Vec::new(),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays994 acting: Some(Box::new(Acting {
995 credential_id: "tok_1".to_owned(),
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent996 agent: "g1t".to_owned(),
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays997 on_behalf_of: Principal {
998 id: "usr_1".to_owned(),
999 username: "syntaqx".to_owned(),
1000 },
1001 scope,
1002 })),
1003 }
1004 }
1005
1006 fn op(kind: RunCredentialKind, usage: CredentialUse, operation: &str) -> Decision {
1007 let scope = scope(kind, usage);
1008 let user = agent(&["acme"], scope.clone());
1009 decide_operation(
1010 &user,
1011 &scope,
1012 operation,
1013 Some(&path("acme", "rocket")),
1014 true,
1015 Some(7),
1016 )
1017 }
1018
1019 use CredentialUse::{Runner, Tools};
1020 use RunCredentialKind as K;
1021
1022 /// Which operations each kind of run may use through its tools: the
1023 /// allowed and denied matrix.
1024 #[test]
1025 fn tools_matrix() {
1026 let cases: [(&str, [bool; 6]); 12] = [
1027 // implement revise answer review plan checks
1028 ("get_issue", [true, true, true, true, true, false]),
1029 ("create_issue", [true, true, true, false, true, false]),
1030 ("add_comment", [true, true, true, true, false, false]),
1031 (
1032 "review_pull_request",
1033 [false, false, false, true, false, false],
1034 ),
1035 ("remember", [true, true, true, false, false, false]),
1036 ("take_messages", [true, true, true, false, false, false]),
1037 ("record_session", [false, false, false, false, false, false]),
1038 (
1039 "merge_pull_request",
1040 [false, false, false, false, false, false],
1041 ),
1042 (
1043 "update_repo_settings",
1044 [false, false, false, false, false, false],
1045 ),
1046 ("create_webhook", [false, false, false, false, false, false]),
1047 (
1048 "set_actions_secret",
1049 [false, false, false, false, false, false],
1050 ),
1051 ("assign_issue", [false, false, false, false, false, false]),
1052 ];
1053 let kinds = [
1054 K::Implement,
1055 K::Revise,
1056 K::Answer,
1057 K::Review,
1058 K::Plan,
1059 K::Checks,
1060 ];
1061 for (operation, expected) in cases {
1062 for (kind, allowed) in kinds.into_iter().zip(expected) {
1063 assert_eq!(
1064 op(kind, Tools, operation).allowed,
1065 allowed,
1066 "{operation} by a {} run's tools",
1067 kind.as_str()
1068 );
1069 }
1070 }
1071 }
1072
1073 #[test]
1074 fn runner_matrix() {
1075 assert!(op(K::Implement, Runner, "record_session").allowed);
1076 assert!(op(K::Implement, Runner, "mark_pull_request_ready").allowed);
1077 assert!(op(K::Revise, Runner, "record_session").allowed);
1078 assert!(!op(K::Revise, Runner, "mark_pull_request_ready").allowed);
1079 assert!(!op(K::Implement, Runner, "create_issue").allowed);
1080 assert!(!op(K::Review, Runner, "record_session").allowed);
1081 assert!(!op(K::Checks, Runner, "get_issue").allowed);
1082 }
1083
1084 #[test]
1085 fn settings_billing_tokens_and_members_are_never_reachable() {
1086 for kind in RunCredentialKind::ALL {
1087 for usage in [Runner, Tools] {
1088 for operation in NEVER.iter().copied() {
1089 let decision = op(kind, usage, operation);
1090 assert!(!decision.allowed);
1091 assert_eq!(decision.rule, "never");
1092 }
1093 }
1094 }
1095 // Even a scope that lists one is refused.
1096 let mut wide = scope(K::Implement, Tools);
1097 wide.operations.push("merge_pull_request".to_owned());
1098 let user = agent(&["acme"], wide.clone());
1099 let decision = decide_operation(
1100 &user,
1101 &wide,
1102 "merge_pull_request",
1103 Some(&path("acme", "rocket")),
1104 true,
1105 Some(7),
1106 );
1107 assert_eq!(decision.rule, "never");
1108 }
1109
1110 #[test]
1111 fn another_repository_is_refused() {
1112 let scope = scope(K::Implement, Tools);
1113 let user = agent(&["acme"], scope.clone());
1114 let decision = decide_operation(
1115 &user,
1116 &scope,
1117 "create_issue",
1118 Some(&path("acme", "other")),
1119 true,
1120 None,
1121 );
1122 assert!(!decision.allowed);
1123 assert_eq!(decision.rule, "scope:repository");
1124 let decision = decide_operation(&user, &scope, "create_issue", None, true, None);
1125 assert_eq!(decision.rule, "scope:repository");
1126 // The repository's name is matched without regard to case.
1127 let decision = decide_operation(
1128 &user,
1129 &scope,
1130 "create_issue",
1131 Some(&path("Acme", "Rocket")),
1132 true,
1133 None,
1134 );
1135 assert!(decision.allowed);
1136 assert_eq!(decision.rule, "run:implement/tools");
1137 }
1138
1139 #[test]
1140 fn the_permission_is_the_intersection_with_the_person() {
1141 let scope = scope(K::Implement, Tools);
1142 // The person left the workspace: their agent can do nothing there.
1143 let user = agent(&[], scope.clone());
1144 let decision = decide_operation(
1145 &user,
1146 &scope,
1147 "get_issue",
1148 Some(&path("acme", "rocket")),
1149 true,
1150 Some(1),
1151 );
1152 assert!(!decision.allowed);
1153 assert_eq!(decision.rule, "on-behalf-of:membership");
1154 // And an owner's agent is only ever a member.
1155 let owner = vec![
1156 Membership {
1157 slug: "acme".to_owned(),
1158 role: Role::Owner,
1159 name: None,
1160 avatar: None,
1161 base_permission: Some(BasePermission::None),
Merge branch 'worktree-agent-ad7c6d88d93adc817'1162 team_creation: None,
Merge main (membership, two-factor, GitHub repo roles) into tokens1163 org_roles: vec![crate::OrgRole::SecurityManager],
1164 privileges: None,
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1165 },
1166 Membership::member("elsewhere"),
1167 ];
1168 let memberships = intersect(&owner, "Acme");
1169 assert_eq!(memberships.len(), 1);
1170 assert_eq!(memberships[0].slug, "acme");
1171 assert_eq!(memberships[0].role, Role::Member);
Merge main (membership, two-factor, GitHub repo roles) into tokens1172 assert!(memberships[0].org_roles.is_empty());
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1173 assert!(intersect(&owner, "nowhere").is_empty());
1174 }
1175
1176 /// An agent gets at most the person's role on the repository, and
1177 /// never more than Write; nothing outside the run's workspace.
1178 #[test]
1179 fn an_agent_has_at_most_its_persons_role() {
1180 use crate::access::{Capability, RepoRef, can, permission};
1181 let rocket = RepoRef { id: "rep_1", namespace: "acme", private: true };
1182 let other = RepoRef { id: "rep_2", namespace: "acme", private: true };
1183 let elsewhere = RepoRef { id: "rep_3", namespace: "globex", private: true };
1184 let tools = scope(K::Implement, Tools);
1185 let scope = scope(K::Implement, Runner);
1186 // An owner's agent: Write, never Admin.
1187 let owner = [Membership { role: Role::Owner, ..Membership::member("acme") }, Membership::member("globex")];
1188 let mut agent_user = agent(&[], scope.clone());
1189 agent_user.workspaces = intersect(&owner, "acme");
1190 assert_eq!(permission(Some(&agent_user), rocket), Some(RepoRole::Write));
1191 assert!(!can(Some(&agent_user), rocket, Capability::ManageSettings));
1192 assert_eq!(permission(Some(&agent_user), elsewhere), None);
1193 // A member whose workspace gives Read: Read, so it cannot push.
1194 let reader = [Membership { base_permission: Some(BasePermission::Read), ..Membership::member("acme") }];
1195 agent_user.workspaces = intersect(&reader, "acme");
1196 assert_eq!(permission(Some(&agent_user), rocket), Some(RepoRole::Read));
1197 assert!(!can(Some(&agent_user), rocket, Capability::Push));
1198 // An outside collaborator with Maintain on one repository: Write
1199 // there, nothing elsewhere, and the run is allowed.
1200 let grants = [
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1201 RepoGrant { repo_id: "rep_1".into(), workspace: "acme".into(), role: RepoRole::Maintain, team: None },
1202 RepoGrant { repo_id: "rep_3".into(), workspace: "globex".into(), role: RepoRole::Admin, team: None },
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1203 ];
1204 agent_user.workspaces = intersect(&[], "acme");
1205 agent_user.grants = intersect_grants(&grants, "Acme");
1206 assert_eq!(permission(Some(&agent_user), rocket), Some(RepoRole::Write));
1207 assert_eq!(permission(Some(&agent_user), other), None);
1208 assert_eq!(permission(Some(&agent_user), elsewhere), None);
1209 let decision = decide_operation(&agent_user, &tools, "get_issue", Some(&path("acme", "rocket")), true, Some(1));
1210 assert!(decision.allowed, "{}", decision.reason.unwrap_or_default());
1211 // The person, downstream of a runner's credential, carries the same.
1212 let person = as_person(&agent_user).expect("a runner acts as the person");
1213 assert_eq!(permission(Some(&person), rocket), Some(RepoRole::Write));
1214 }
1215
1216 #[test]
1217 fn a_runner_changes_only_its_own_pull_request() {
1218 let scope = scope(K::Implement, Runner);
1219 let user = agent(&["acme"], scope.clone());
1220 let repo = path("acme", "rocket");
1221 let other = decide_operation(&user, &scope, "record_session", Some(&repo), true, Some(8));
1222 assert!(!other.allowed);
1223 assert_eq!(other.rule, "scope:pull");
1224 let own = decide_operation(&user, &scope, "record_session", Some(&repo), true, Some(7));
1225 assert!(own.allowed);
1226 // Reading another is fine.
1227 assert!(
1228 decide_operation(
1229 &user,
1230 &scope,
1231 "get_pull_request",
1232 Some(&repo),
1233 true,
1234 Some(8)
1235 )
1236 .allowed
1237 );
1238 }
1239
1240 #[test]
1241 fn git_matrix() {
1242 let fork = path("pulls", "pul_7");
1243 let upstream = path("acme", "rocket");
1244 let elsewhere = path("acme", "billing");
1245 let implement = scope(K::Implement, Runner);
1246 assert!(decide_git(&implement, &fork, true).allowed);
1247 assert!(decide_git(&implement, &fork, false).allowed);
1248 assert!(decide_git(&implement, &upstream, false).allowed);
1249 assert_eq!(decide_git(&implement, &upstream, true).rule, "git:push");
1250 assert_eq!(decide_git(&implement, &elsewhere, false).rule, "git:read");
1251 let review = scope(K::Review, Runner);
1252 assert!(decide_git(&review, &upstream, false).allowed);
1253 assert!(!decide_git(&review, &upstream, true).allowed);
1254 assert!(!decide_git(&review, &fork, true).allowed);
1255 // A tools token made before run credentials never reaches git.
1256 let old = AgentScope {
1257 repo: upstream.clone(),
1258 operations: vec!["get_issue".to_owned()],
1259 run: None,
1260 };
1261 assert_eq!(decide_git(&old, &upstream, false).rule, "git:not-a-run");
1262 // Nor does an agent's tools token.
1263 assert_eq!(
1264 decide_git(&scope(K::Implement, Tools), &upstream, false).rule,
1265 "git:not-a-run"
1266 );
1267 }
1268
1269 #[test]
1270 fn a_push_moves_only_granted_branches() {
1271 let update = scope(K::Update, Runner);
1272 let repo = path("acme", "rocket");
1273 let refs = |names: &[&str]| {
1274 names
1275 .iter()
1276 .map(|name| (*name).to_owned())
1277 .collect::<Vec<_>>()
1278 };
1279 assert!(decide_refs(&update, &repo, &refs(&["refs/heads/fix-login"])).allowed);
1280 assert_eq!(
1281 decide_refs(&update, &repo, &refs(&["refs/heads/main"])).rule,
1282 "git:ref"
1283 );
1284 assert_eq!(
1285 decide_refs(
1286 &update,
1287 &repo,
1288 &refs(&["refs/heads/fix-login", "refs/tags/v1"])
1289 )
1290 .rule,
1291 "git:ref"
1292 );
1293 let implement = scope(K::Implement, Runner);
1294 assert!(
1295 decide_refs(
1296 &implement,
1297 &path("pulls", "pul_7"),
1298 &refs(&["refs/heads/main"])
1299 )
1300 .allowed
1301 );
1302 }
1303
1304 #[test]
1305 fn a_runner_acts_downstream_as_the_person() {
1306 let user = agent(&["acme"], scope(K::Implement, Runner));
1307 let person = as_person(&user).unwrap();
1308 assert_eq!(person.id, "usr_1");
1309 assert_eq!(person.username, "syntaqx");
1310 assert_eq!(person.kind, PrincipalKind::User);
1311 assert!(person.is_member("acme"));
1312 assert!(person.acting.is_none());
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1313 assert_eq!(describe(&user), "g1t on behalf of syntaqx");
Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays1314 // The tools act as the agent.
1315 assert!(as_person(&agent(&["acme"], scope(K::Implement, Tools))).is_none());
1316 }
1317
1318 #[test]
1319 fn scopes_without_a_run_still_parse() {
1320 let old: AgentScope = serde_json::from_str(
1321 r#"{"repo":{"namespace":"acme","name":"rocket"},"operations":["get_issue"]}"#,
1322 )
1323 .unwrap();
1324 assert!(old.run.is_none());
1325 let written = serde_json::to_string(&scope(K::Review, Tools)).unwrap();
1326 assert!(written.contains(r#""use":"tools""#));
1327 assert!(written.contains(r#""kind":"review""#));
1328 let back: AgentScope = serde_json::from_str(&written).unwrap();
1329 assert_eq!(back.run.unwrap().kind, K::Review);
1330 // Only g1t's own runs say so; every other reads as not.
1331 assert!(!written.contains("system"));
1332 assert!(!back_run(&written).system);
1333 let mut own = scope(K::Bump, Runner);
1334 own.run.as_mut().unwrap().system = true;
1335 let written = serde_json::to_string(&own).unwrap();
1336 assert!(written.contains(r#""system":true"#));
1337 assert!(back_run(&written).system);
1338 }
1339
1340 fn back_run(written: &str) -> RunBinding {
1341 serde_json::from_str::<AgentScope>(written).unwrap().run.unwrap()
1342 }
1343}

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