Skip to content
1,343 linesCodeBlameRaw
1//! 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>,
130 /// The agent's name, such as `g1t`.
131 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
153impl 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
162/// Set on a [`User`] resolved from an agent's token: the composite
163/// identity, "g1t on behalf of syntaqx", and what it may do.
164#[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,
199 /// Defaults to `g1t`.
200 #[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",
230 "get_usage",
231 "get_budget",
232 "get_ai_credit",
233 "list_invoices",
234 "get_billing_details",
235 "list_repos",
236 "get_repo",
237 "list_projects",
238 "get_project",
239 "list_deleted_repos",
240 "list_collaborators",
241 "get_collaborator_permission",
242 "list_repo_invitations",
243 "list_my_repo_invitations",
244 "list_outside_collaborators",
245 "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",
252 "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",
260 "list_issue_labels",
261 "list_milestones",
262 "get_milestone",
263 "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",
276 "get_pending_deployments",
277 "get_workflow_permissions",
278 "get_fork_pr_approval",
279 "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",
286 "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",
293 "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",
310 "list_security_advisories",
311 "get_security_advisory",
312 "list_vulnerability_reports",
313 "list_notifications",
314 "get_notification_thread",
315 "get_thread_subscription",
316 "get_repo_subscription",
317 "list_watched_repos",
318 "list_pinned_projects",
319];
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] = &[
326 // Billing is people's: agents never spend or change it.
327 "set_budget",
328 "buy_ai_credit",
329 "create_workspace",
330 "delete_workspace",
331 "update_workspace",
332 // 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",
338 "transfer_repo",
339 "create_repo",
340 "update_repo",
341 "update_project",
342 "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",
351 "delete_branch",
352 "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",
360 "update_integration",
361 "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",
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",
383 "set_actions_access",
384 "create_repository_dispatch",
385 "set_workspace_workflow_permissions",
386 // 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",
393 "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",
409 // Nor answers a workspace invitation: only the person it is for does.
410 "accept_invitation",
411 "decline_invitation",
412 "set_base_permission",
413 "list_outside_collaborators",
414 // 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",
419 // 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",
425 // 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",
434 // Dismissing a secret lets it through push protection.
435 "dismiss_security_alert",
436 "reopen_security_alert",
437 // 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",
452 // 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",
467 // Pins are a person's own, as the inbox is.
468 "list_pinned_projects",
469 "pin_project",
470 "unpin_project",
471 "reorder_pinned_projects",
472 // 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",
485 // Sharing an artifact and deleting one for good are for people: an
486 // agent shares only through the agents service, with the people
487 // already in its conversation.
488 "set_workspace_artifact_access",
489 "purge_workspace_artifact",
490];
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",
498 "list_issue_labels",
499 "list_milestones",
500 "get_milestone",
501 "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",
515 "get_pending_deployments",
516 "get_workflow_permissions",
517 "get_fork_pr_approval",
518 "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",
525];
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",
556 "customize_bundle",
557 ]);
558 }
559 K::Review => {
560 operations.extend(TOOLS_READ.iter().copied());
561 operations.extend(["add_comment", "review_pull_request", "get_context", "customize_bundle"]);
562 }
563 K::Plan => {
564 operations.extend(TOOLS_READ.iter().copied());
565 operations.extend(["create_issue", "get_context", "customize_bundle"]);
566 }
567 K::Update => {
568 operations.extend(TOOLS_READ.iter().copied());
569 operations.push("customize_bundle");
570 }
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 }),
829 // Billing and security manager are the person's, never
830 // their agent's.
831 org_roles: Vec::new(),
832 ..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(),
866 display_username: None,
867 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,
874 held: Vec::new(),
875 })
876}
877
878/// How an actor is described: "g1t on behalf of syntaqx".
879pub 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),
959 agent: "g1t".to_owned(),
960 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(),
982 username: "g1t".to_owned(),
983 display_username: None,
984 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,
993 held: Vec::new(),
994 acting: Some(Box::new(Acting {
995 credential_id: "tok_1".to_owned(),
996 agent: "g1t".to_owned(),
997 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),
1162 team_creation: None,
1163 org_roles: vec![crate::OrgRole::SecurityManager],
1164 privileges: None,
1165 },
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);
1172 assert!(memberships[0].org_roles.is_empty());
1173 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 = [
1201 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 },
1203 ];
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());
1313 assert_eq!(describe(&user), "g1t on behalf of syntaqx");
1314 // 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}