Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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