Skip to content
1,237 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 "list_repos",
231 "get_repo",
232 "list_deleted_repos",
233 "list_collaborators",
234 "get_collaborator_permission",
235 "list_repo_invitations",
236 "list_my_repo_invitations",
237 "list_outside_collaborators",
238 "list_teams",
239 "get_team",
240 "list_team_members",
241 "list_child_teams",
242 "list_team_repos",
243 "list_user_teams",
244 "get_codeowners_errors",
245 "get_repo_settings",
246 "list_check_names",
247 "get_merge_queue",
248 "recall",
249 "list_issues",
250 "get_issue",
251 "get_plan",
252 "list_labels",
253 "list_issue_labels",
254 "list_milestones",
255 "get_milestone",
256 "list_pull_requests",
257 "get_pull_request",
258 "read_session",
259 "get_pull_request_changes",
260 "list_events",
261 "get_context",
262 "search_context",
263 "get_entity",
264 "search",
265 "list_workflows",
266 "list_workflow_runs",
267 "get_workflow_run",
268 "get_job_logs",
269 "list_integrations",
270 "get_model_routes",
271 "list_webhooks",
272 "list_webhook_deliveries",
273 "list_actions_secrets",
274 "list_actions_variables",
275 "list_security_alerts",
276 "list_secret_scanning_alerts",
277 "get_secret_scanning_alert",
278 "list_secret_scanning_locations",
279 "list_bypass_requests",
280 "list_custom_patterns",
281 "list_code_scanning_alerts",
282 "get_code_scanning_alert",
283 "list_code_scanning_analyses",
284 "get_sarif_upload",
285 "list_vulnerability_alerts",
286 "get_vulnerability_alert",
287 "get_dependency_graph",
288 "get_sbom",
289 "compare_dependencies",
290 "get_security_settings",
291 "get_workspace_security_settings",
292 "get_security_overview",
293 "list_notifications",
294 "get_notification_thread",
295 "get_thread_subscription",
296 "get_repo_subscription",
297 "list_watched_repos",
298 "list_pinned_projects",
299];
300
301/// What no agent's token may ever do, whatever its scope says: workspaces,
302/// repositories' settings, members, tokens, billing, integrations,
303/// webhooks, secrets, workflows' controls, merging, and putting more agents
304/// to work.
305pub const NEVER: &[&str] = &[
306 "create_workspace",
307 "delete_workspace",
308 "update_workspace",
309 "transfer_repo",
310 "create_repo",
311 "update_repo",
312 "delete_repo",
313 "list_deleted_repos",
314 "restore_repo",
315 "purge_repo",
316 "rename_repo",
317 "archive_repo",
318 "unarchive_repo",
319 "set_repo_visibility",
320 "rename_branch",
321 "update_repo_settings",
322 "merge_pull_request",
323 "assign_issue",
324 "plan_work",
325 "apply_plan",
326 "import_issue",
327 "list_integrations",
328 "connect_integration",
329 "disconnect_integration",
330 "test_integration",
331 "get_model_routes",
332 "set_model_routes",
333 "list_webhooks",
334 "create_webhook",
335 "update_webhook",
336 "delete_webhook",
337 "ping_webhook",
338 "list_webhook_deliveries",
339 "redeliver_webhook",
340 "dispatch_workflow",
341 "cancel_workflow_run",
342 "rerun_workflow_run",
343 "update_workflow",
344 "list_actions_secrets",
345 "set_actions_secret",
346 "delete_actions_secret",
347 "list_actions_variables",
348 "set_actions_variable",
349 "delete_actions_variable",
350 "list_collaborators",
351 "get_collaborator_permission",
352 "add_collaborator",
353 "update_collaborator",
354 "remove_collaborator",
355 "list_repo_invitations",
356 "revoke_repo_invitation",
357 "list_my_repo_invitations",
358 "accept_repo_invitation",
359 "decline_repo_invitation",
360 "set_base_permission",
361 "list_outside_collaborators",
362 // Teams: who is in which, and what they reach, is for people.
363 "create_team",
364 "update_team",
365 "delete_team",
366 "set_team_member",
367 "remove_team_member",
368 "set_team_repo",
369 "remove_team_repo",
370 "set_team_review_assignment",
371 // Dismissing a secret lets it through push protection.
372 "dismiss_security_alert",
373 "reopen_security_alert",
374 // Nor any other decision about security: closing or reopening an
375 // alert, pushing past push protection or deciding who may, changing
376 // what is looked for or when checks fail, or putting more agents to
377 // work. An agent fixes what it finds in its own pull request.
378 "update_secret_scanning_alert",
379 "bypass_push_protection",
380 "review_bypass_request",
381 "create_custom_pattern",
382 "update_custom_pattern",
383 "delete_custom_pattern",
384 "update_code_scanning_alert",
385 "update_vulnerability_alert",
386 "fix_security_alert",
387 "update_security_settings",
388 "update_workspace_security_settings",
389 // A person's own inbox: g1t's agents act as g1t, which has none.
390 "list_notifications",
391 "get_notification_thread",
392 "mark_notifications_read",
393 "mark_thread_read",
394 "mark_thread_done",
395 "save_thread",
396 "snooze_thread",
397 "get_thread_subscription",
398 "set_thread_subscription",
399 "delete_thread_subscription",
400 "get_repo_subscription",
401 "set_repo_subscription",
402 "delete_repo_subscription",
403 "list_watched_repos",
404 // Pins are a person's own, as the inbox is.
405 "list_pinned_projects",
406 "pin_project",
407 "unpin_project",
408 "reorder_pinned_projects",
409];
410
411/// Reading what an agent needs to know about its repository.
412const TOOLS_READ: &[&str] = &[
413 "get_repo",
414 "list_issues",
415 "get_issue",
416 "list_labels",
417 "list_issue_labels",
418 "list_milestones",
419 "get_milestone",
420 "list_pull_requests",
421 "get_pull_request",
422 "get_pull_request_changes",
423 "read_session",
424 "get_merge_queue",
425 "list_events",
426 "recall",
427 "search_context",
428 "get_entity",
429 "search",
430 "list_workflows",
431 "list_workflow_runs",
432 "get_workflow_run",
433 "get_job_logs",
434];
435
436pub fn is_read(operation: &str) -> bool {
437 READ_OPERATIONS.contains(&operation)
438}
439
440/// The API and MCP operations a run of `kind` may use with a credential
441/// for `usage`. Git is separate: see [`decide_git`].
442pub fn operations_for(kind: RunCredentialKind, usage: CredentialUse) -> Vec<&'static str> {
443 use RunCredentialKind as K;
444 let mut operations: Vec<&'static str> = Vec::new();
445 match usage {
446 CredentialUse::Runner => {
447 if kind.works_on_a_pull() {
448 operations.extend(["get_repo", "get_pull_request", "record_session"]);
449 }
450 if kind == K::Implement {
451 operations.push("mark_pull_request_ready");
452 }
453 }
454 CredentialUse::Tools => match kind {
455 K::Implement | K::Revise | K::Answer => {
456 operations.extend(TOOLS_READ.iter().copied());
457 operations.extend([
458 "create_issue",
459 "add_comment",
460 "take_messages",
461 "remember",
462 "message_agent",
463 "answer_message",
464 "get_context",
465 ]);
466 }
467 K::Review => {
468 operations.extend(TOOLS_READ.iter().copied());
469 operations.extend(["add_comment", "review_pull_request", "get_context"]);
470 }
471 K::Plan => {
472 operations.extend(TOOLS_READ.iter().copied());
473 operations.extend(["create_issue", "get_context"]);
474 }
475 K::Update => operations.extend(TOOLS_READ.iter().copied()),
476 K::Checks | K::Queue | K::Mergecheck | K::Deploy | K::Bump => {}
477 },
478 }
479 operations
480}
481
482/// What a run's credential may do, in the scope vocabulary that access
483/// tokens use (see [`crate::scopes`]): the scopes of its operations, and
484/// for a runner, git's. Its operations, its repository and its run still
485/// bound it more tightly than these scopes say.
486pub fn run_scopes(kind: RunCredentialKind, usage: CredentialUse) -> Vec<crate::scopes::Scope> {
487 use crate::scopes::{Scope, normalize, scope_for};
488 let mut scopes: Vec<Scope> = operations_for(kind, usage)
489 .into_iter()
490 .filter_map(scope_for)
491 .collect();
492 if usage == CredentialUse::Runner {
493 scopes.push(Scope::CodeRead);
494 if matches!(
495 kind,
496 RunCredentialKind::Implement
497 | RunCredentialKind::Revise
498 | RunCredentialKind::Answer
499 | RunCredentialKind::Update
500 | RunCredentialKind::Bump
501 ) {
502 scopes.push(Scope::CodeWrite);
503 }
504 }
505 normalize(&mut scopes);
506 scopes
507}
508
509/// Operations that change a pull request, which a runner may do only to
510/// the pull request its run works on.
511const PULL_WRITES: &[&str] = &["record_session", "mark_pull_request_ready"];
512
513/// Whether something was allowed, and the rule that decided it.
514#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
515pub struct Decision {
516 pub allowed: bool,
517 /// A short, stable name: `run:implement/tools`, `never`,
518 /// `scope:repository` and so on. Shown in the audit log.
519 pub rule: String,
520 /// Why it was refused, for the caller.
521 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub reason: Option<String>,
523}
524
525impl Decision {
526 pub fn allow(rule: impl Into<String>) -> Self {
527 Decision {
528 allowed: true,
529 rule: rule.into(),
530 reason: None,
531 }
532 }
533
534 pub fn deny(rule: impl Into<String>, reason: impl Into<String>) -> Self {
535 Decision {
536 allowed: false,
537 rule: rule.into(),
538 reason: Some(reason.into()),
539 }
540 }
541}
542
543fn same_repo(a: &RepoPath, b: &RepoPath) -> bool {
544 a.namespace.eq_ignore_ascii_case(&b.namespace) && a.name.eq_ignore_ascii_case(&b.name)
545}
546
547fn scope_rule(scope: &AgentScope) -> String {
548 match &scope.run {
549 Some(run) => format!("run:{}/{}", run.kind.as_str(), run.usage.as_str()),
550 None => "agent-token".to_owned(),
551 }
552}
553
554/// Whether `user`, resolved from an agent's token with `scope`, may use
555/// `operation`. `repo` is the repository the call names, if any, and
556/// `needs_repo` whether the operation is about one; `number` the issue or
557/// pull request it names.
558pub fn decide_operation(
559 user: &User,
560 scope: &AgentScope,
561 operation: &str,
562 repo: Option<&RepoPath>,
563 needs_repo: bool,
564 number: Option<u32>,
565) -> Decision {
566 let who = "A g1t agent's token";
567 if NEVER.contains(&operation) {
568 return Decision::deny(
569 "never",
570 format!(
571 "{who} can never use {operation}: settings, members, tokens, billing, integrations, webhooks, secrets and merging are for people."
572 ),
573 );
574 }
575 if !scope.operations.iter().any(|name| name == operation) {
576 return Decision::deny(
577 "scope:operation",
578 format!("{who} for this run cannot use {operation}."),
579 );
580 }
581 if needs_repo && !repo.is_some_and(|asked| same_repo(asked, &scope.repo)) {
582 return Decision::deny(
583 "scope:repository",
584 format!(
585 "{who} works in {}/{} only.",
586 scope.repo.namespace, scope.repo.name
587 ),
588 );
589 }
590 // The intersection: the person it acts for must still be able to work
591 // in the repository's workspace, as a member or with a role on its
592 // repositories. What it may do in the repository itself is their
593 // role there, which services check (`access::can`).
594 if !crate::access::has_access_in(user, &scope.repo.namespace) {
595 return Decision::deny(
596 "on-behalf-of:membership",
597 format!(
598 "The person this agent works for is no longer a member of {}.",
599 scope.repo.namespace
600 ),
601 );
602 }
603 if let Some(run) = &scope.run
604 && run.usage == CredentialUse::Runner
605 && PULL_WRITES.contains(&operation)
606 && run.number.is_some()
607 && number != run.number
608 {
609 return Decision::deny(
610 "scope:pull",
611 format!(
612 "{who} can change pull request #{} only.",
613 run.number.unwrap_or_default()
614 ),
615 );
616 }
617 Decision::allow(scope_rule(scope))
618}
619
620/// Whether a run credential may clone or fetch (`write` false), or push to
621/// (`write` true), the repository at `repo`.
622pub fn decide_git(scope: &AgentScope, repo: &RepoPath, write: bool) -> Decision {
623 let Some(run) = scope
624 .run
625 .as_ref()
626 .filter(|run| run.usage == CredentialUse::Runner)
627 else {
628 return Decision::deny(
629 "git:not-a-run",
630 "A g1t agent's tools token cannot be used with git.",
631 );
632 };
633 let pushable = run.push.iter().any(|grant| same_repo(&grant.repo, repo));
634 if write {
635 return if pushable {
636 Decision::allow(format!("{}:push", scope_rule(scope)))
637 } else {
638 Decision::deny(
639 "git:push",
640 format!(
641 "A {} run cannot push to {}/{}.",
642 run.kind.as_str(),
643 repo.namespace,
644 repo.name
645 ),
646 )
647 };
648 }
649 let readable = pushable
650 || same_repo(&scope.repo, repo)
651 || run.read.iter().any(|path| same_repo(path, repo));
652 if readable {
653 Decision::allow(format!("{}:read", scope_rule(scope)))
654 } else {
655 Decision::deny(
656 "git:read",
657 format!(
658 "A {} run cannot read {}/{}.",
659 run.kind.as_str(),
660 repo.namespace,
661 repo.name
662 ),
663 )
664 }
665}
666
667/// Whether a push to `repo` is limited to certain branches, so that the
668/// refs it moves have to be read and checked with [`decide_refs`].
669pub fn limits_branches(scope: &AgentScope, repo: &RepoPath) -> bool {
670 scope
671 .run
672 .iter()
673 .flat_map(|run| run.push.iter())
674 .any(|grant| same_repo(&grant.repo, repo) && grant.branch.is_some())
675}
676
677/// Whether a push to `repo` may move `refs` (full refs, such as
678/// `refs/heads/main`). Tags are never a run's to move.
679pub fn decide_refs(scope: &AgentScope, repo: &RepoPath, refs: &[String]) -> Decision {
680 let repo_decision = decide_git(scope, repo, true);
681 if !repo_decision.allowed {
682 return repo_decision;
683 }
684 let grants: Vec<&GitGrant> = scope
685 .run
686 .iter()
687 .flat_map(|run| run.push.iter())
688 .filter(|grant| same_repo(&grant.repo, repo))
689 .collect();
690 for git_ref in refs {
691 let Some(branch) = git_ref.strip_prefix("refs/heads/") else {
692 return Decision::deny("git:ref", format!("A run cannot push {git_ref}."));
693 };
694 let allowed = grants
695 .iter()
696 .any(|grant| grant.branch.as_deref().is_none_or(|only| only == branch));
697 if !allowed {
698 return Decision::deny(
699 "git:ref",
700 format!(
701 "A run cannot push to {branch} in {}/{}.",
702 repo.namespace, repo.name
703 ),
704 );
705 }
706 }
707 repo_decision
708}
709
710/// The most an agent may be on a repository, whoever it works for: it
711/// can push, merge and run, never change settings or who has access.
712pub const AGENT_CEILING: RepoRole = RepoRole::Write;
713
714/// The memberships an agent working for `person` has: the run's
715/// workspace, as a member, only if the person is in it now, with the
716/// person's role on its repositories (an owner's Admin included) cut down
717/// to [`AGENT_CEILING`].
718pub fn intersect(person: &[Membership], namespace: &str) -> Vec<Membership> {
719 let namespace = namespace.to_lowercase();
720 person
721 .iter()
722 .filter(|membership| membership.slug == namespace)
723 .map(|membership| {
724 let base = match membership.role {
725 Role::Owner => BasePermission::Admin,
726 Role::Member => membership.base_permission.unwrap_or_default(),
727 };
728 Membership {
729 role: Role::Member,
730 base_permission: Some(match base {
731 BasePermission::Admin => BasePermission::Write,
732 base => base,
733 }),
734 ..membership.clone()
735 }
736 })
737 .collect()
738}
739
740/// The repository grants an agent working for `person` has: those in the
741/// run's workspace, each cut down to [`AGENT_CEILING`].
742pub fn intersect_grants(person: &[RepoGrant], namespace: &str) -> Vec<RepoGrant> {
743 let namespace = namespace.to_lowercase();
744 person
745 .iter()
746 .filter(|grant| grant.workspace == namespace)
747 .map(|grant| RepoGrant {
748 role: grant.role.min(AGENT_CEILING),
749 ..grant.clone()
750 })
751 .collect()
752}
753
754/// Who a runner's credential acts as downstream: the person, with only the
755/// agent's (already intersected) memberships. `None` for anything else.
756pub fn as_person(user: &User) -> Option<User> {
757 let acting = user.acting.as_ref()?;
758 if user.kind != PrincipalKind::Agent {
759 return None;
760 }
761 let run = acting.run()?;
762 if run.usage != CredentialUse::Runner {
763 return None;
764 }
765 Some(User {
766 id: acting.on_behalf_of.id.clone(),
767 username: acting.on_behalf_of.username.clone(),
768 kind: PrincipalKind::User,
769 verified: user.verified,
770 workspaces: user.workspaces.clone(),
771 avatar: None,
772 acting: None,
773 grants: user.grants.clone(),
774 token: None,
775 })
776}
777
778/// How an actor is described: "g1t on behalf of syntaqx".
779pub fn describe(user: &User) -> String {
780 match &user.acting {
781 Some(acting) => format!(
782 "{} on behalf of {}",
783 acting.agent, acting.on_behalf_of.username
784 ),
785 None => user.username.clone(),
786 }
787}
788
789#[cfg(test)]
790mod tests {
791 use super::*;
792
793 #[test]
794 fn a_run_s_scopes_are_never_admin() {
795 for kind in RunCredentialKind::ALL {
796 for usage in [CredentialUse::Runner, CredentialUse::Tools] {
797 let scopes = run_scopes(kind, usage);
798 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{kind:?} {usage:?}: {scopes:?}");
799 }
800 }
801 let review = run_scopes(RunCredentialKind::Review, CredentialUse::Tools);
802 assert!(review.contains(&crate::scopes::Scope::PullRequestsWrite));
803 assert!(!review.contains(&crate::scopes::Scope::CodeWrite));
804 }
805
806 #[test]
807 fn agents_can_search_the_context_hub() {
808 for kind in [RunCredentialKind::Implement, RunCredentialKind::Review, RunCredentialKind::Plan] {
809 let tools = operations_for(kind, CredentialUse::Tools);
810 assert!(tools.contains(&"search_context") && tools.contains(&"get_entity"));
811 }
812 assert!(is_read("search_context") && is_read("get_entity"));
813 }
814
815 #[test]
816 fn agents_can_search_all_of_g1t() {
817 // Site-wide search only reads: every run that reads its repository
818 // may use it, and nothing that never reads gets it.
819 assert!(is_read("search"));
820 assert!(!NEVER.contains(&"search"));
821 for kind in [
822 RunCredentialKind::Implement,
823 RunCredentialKind::Revise,
824 RunCredentialKind::Answer,
825 RunCredentialKind::Review,
826 RunCredentialKind::Plan,
827 RunCredentialKind::Update,
828 ] {
829 let tools = operations_for(kind, CredentialUse::Tools);
830 assert!(tools.contains(&"search"), "{kind:?} should search");
831 // The context hub's search stays its own tool beside it.
832 assert!(tools.contains(&"search_context"), "{kind:?} keeps search_context");
833 }
834 for kind in [RunCredentialKind::Checks, RunCredentialKind::Queue, RunCredentialKind::Mergecheck, RunCredentialKind::Deploy, RunCredentialKind::Bump] {
835 assert!(!operations_for(kind, CredentialUse::Tools).contains(&"search"));
836 }
837 assert!(!operations_for(RunCredentialKind::Implement, CredentialUse::Runner).contains(&"search"));
838 }
839
840 fn path(namespace: &str, name: &str) -> RepoPath {
841 RepoPath {
842 namespace: namespace.to_owned(),
843 name: name.to_owned(),
844 }
845 }
846
847 fn scope(kind: RunCredentialKind, usage: CredentialUse) -> AgentScope {
848 AgentScope {
849 repo: path("acme", "rocket"),
850 operations: operations_for(kind, usage)
851 .into_iter()
852 .map(str::to_owned)
853 .collect(),
854 run: Some(RunBinding {
855 kind,
856 usage,
857 run_id: Some("run_1".to_owned()),
858 number: Some(7),
859 agent: "g1t".to_owned(),
860 system: false,
861 read: vec![path("acme", "rocket")],
862 push: match kind {
863 RunCredentialKind::Implement
864 | RunCredentialKind::Revise
865 | RunCredentialKind::Answer => vec![GitGrant {
866 repo: path("pulls", "pul_7"),
867 branch: None,
868 }],
869 RunCredentialKind::Update => vec![GitGrant {
870 repo: path("acme", "rocket"),
871 branch: Some("fix-login".to_owned()),
872 }],
873 _ => vec![],
874 },
875 }),
876 }
877 }
878
879 fn agent(member_of: &[&str], scope: AgentScope) -> User {
880 User {
881 id: "usr_g1t_agent".to_owned(),
882 username: "g1t".to_owned(),
883 kind: PrincipalKind::Agent,
884 verified: true,
885 workspaces: member_of
886 .iter()
887 .map(|slug| Membership::member(*slug))
888 .collect(),
889 avatar: None,
890 grants: Vec::new(),
891 token: None,
892 acting: Some(Box::new(Acting {
893 credential_id: "tok_1".to_owned(),
894 agent: "g1t".to_owned(),
895 on_behalf_of: Principal {
896 id: "usr_1".to_owned(),
897 username: "syntaqx".to_owned(),
898 },
899 scope,
900 })),
901 }
902 }
903
904 fn op(kind: RunCredentialKind, usage: CredentialUse, operation: &str) -> Decision {
905 let scope = scope(kind, usage);
906 let user = agent(&["acme"], scope.clone());
907 decide_operation(
908 &user,
909 &scope,
910 operation,
911 Some(&path("acme", "rocket")),
912 true,
913 Some(7),
914 )
915 }
916
917 use CredentialUse::{Runner, Tools};
918 use RunCredentialKind as K;
919
920 /// Which operations each kind of run may use through its tools: the
921 /// allowed and denied matrix.
922 #[test]
923 fn tools_matrix() {
924 let cases: [(&str, [bool; 6]); 12] = [
925 // implement revise answer review plan checks
926 ("get_issue", [true, true, true, true, true, false]),
927 ("create_issue", [true, true, true, false, true, false]),
928 ("add_comment", [true, true, true, true, false, false]),
929 (
930 "review_pull_request",
931 [false, false, false, true, false, false],
932 ),
933 ("remember", [true, true, true, false, false, false]),
934 ("take_messages", [true, true, true, false, false, false]),
935 ("record_session", [false, false, false, false, false, false]),
936 (
937 "merge_pull_request",
938 [false, false, false, false, false, false],
939 ),
940 (
941 "update_repo_settings",
942 [false, false, false, false, false, false],
943 ),
944 ("create_webhook", [false, false, false, false, false, false]),
945 (
946 "set_actions_secret",
947 [false, false, false, false, false, false],
948 ),
949 ("assign_issue", [false, false, false, false, false, false]),
950 ];
951 let kinds = [
952 K::Implement,
953 K::Revise,
954 K::Answer,
955 K::Review,
956 K::Plan,
957 K::Checks,
958 ];
959 for (operation, expected) in cases {
960 for (kind, allowed) in kinds.into_iter().zip(expected) {
961 assert_eq!(
962 op(kind, Tools, operation).allowed,
963 allowed,
964 "{operation} by a {} run's tools",
965 kind.as_str()
966 );
967 }
968 }
969 }
970
971 #[test]
972 fn runner_matrix() {
973 assert!(op(K::Implement, Runner, "record_session").allowed);
974 assert!(op(K::Implement, Runner, "mark_pull_request_ready").allowed);
975 assert!(op(K::Revise, Runner, "record_session").allowed);
976 assert!(!op(K::Revise, Runner, "mark_pull_request_ready").allowed);
977 assert!(!op(K::Implement, Runner, "create_issue").allowed);
978 assert!(!op(K::Review, Runner, "record_session").allowed);
979 assert!(!op(K::Checks, Runner, "get_issue").allowed);
980 }
981
982 #[test]
983 fn settings_billing_tokens_and_members_are_never_reachable() {
984 for kind in RunCredentialKind::ALL {
985 for usage in [Runner, Tools] {
986 for operation in NEVER.iter().copied() {
987 let decision = op(kind, usage, operation);
988 assert!(!decision.allowed);
989 assert_eq!(decision.rule, "never");
990 }
991 }
992 }
993 // Even a scope that lists one is refused.
994 let mut wide = scope(K::Implement, Tools);
995 wide.operations.push("merge_pull_request".to_owned());
996 let user = agent(&["acme"], wide.clone());
997 let decision = decide_operation(
998 &user,
999 &wide,
1000 "merge_pull_request",
1001 Some(&path("acme", "rocket")),
1002 true,
1003 Some(7),
1004 );
1005 assert_eq!(decision.rule, "never");
1006 }
1007
1008 #[test]
1009 fn another_repository_is_refused() {
1010 let scope = scope(K::Implement, Tools);
1011 let user = agent(&["acme"], scope.clone());
1012 let decision = decide_operation(
1013 &user,
1014 &scope,
1015 "create_issue",
1016 Some(&path("acme", "other")),
1017 true,
1018 None,
1019 );
1020 assert!(!decision.allowed);
1021 assert_eq!(decision.rule, "scope:repository");
1022 let decision = decide_operation(&user, &scope, "create_issue", None, true, None);
1023 assert_eq!(decision.rule, "scope:repository");
1024 // The repository's name is matched without regard to case.
1025 let decision = decide_operation(
1026 &user,
1027 &scope,
1028 "create_issue",
1029 Some(&path("Acme", "Rocket")),
1030 true,
1031 None,
1032 );
1033 assert!(decision.allowed);
1034 assert_eq!(decision.rule, "run:implement/tools");
1035 }
1036
1037 #[test]
1038 fn the_permission_is_the_intersection_with_the_person() {
1039 let scope = scope(K::Implement, Tools);
1040 // The person left the workspace: their agent can do nothing there.
1041 let user = agent(&[], scope.clone());
1042 let decision = decide_operation(
1043 &user,
1044 &scope,
1045 "get_issue",
1046 Some(&path("acme", "rocket")),
1047 true,
1048 Some(1),
1049 );
1050 assert!(!decision.allowed);
1051 assert_eq!(decision.rule, "on-behalf-of:membership");
1052 // And an owner's agent is only ever a member.
1053 let owner = vec![
1054 Membership {
1055 slug: "acme".to_owned(),
1056 role: Role::Owner,
1057 name: None,
1058 avatar: None,
1059 base_permission: Some(BasePermission::None),
1060 },
1061 Membership::member("elsewhere"),
1062 ];
1063 let memberships = intersect(&owner, "Acme");
1064 assert_eq!(memberships.len(), 1);
1065 assert_eq!(memberships[0].slug, "acme");
1066 assert_eq!(memberships[0].role, Role::Member);
1067 assert!(intersect(&owner, "nowhere").is_empty());
1068 }
1069
1070 /// An agent gets at most the person's role on the repository, and
1071 /// never more than Write; nothing outside the run's workspace.
1072 #[test]
1073 fn an_agent_has_at_most_its_persons_role() {
1074 use crate::access::{Capability, RepoRef, can, permission};
1075 let rocket = RepoRef { id: "rep_1", namespace: "acme", private: true };
1076 let other = RepoRef { id: "rep_2", namespace: "acme", private: true };
1077 let elsewhere = RepoRef { id: "rep_3", namespace: "globex", private: true };
1078 let tools = scope(K::Implement, Tools);
1079 let scope = scope(K::Implement, Runner);
1080 // An owner's agent: Write, never Admin.
1081 let owner = [Membership { role: Role::Owner, ..Membership::member("acme") }, Membership::member("globex")];
1082 let mut agent_user = agent(&[], scope.clone());
1083 agent_user.workspaces = intersect(&owner, "acme");
1084 assert_eq!(permission(Some(&agent_user), rocket), Some(RepoRole::Write));
1085 assert!(!can(Some(&agent_user), rocket, Capability::ManageSettings));
1086 assert_eq!(permission(Some(&agent_user), elsewhere), None);
1087 // A member whose workspace gives Read: Read, so it cannot push.
1088 let reader = [Membership { base_permission: Some(BasePermission::Read), ..Membership::member("acme") }];
1089 agent_user.workspaces = intersect(&reader, "acme");
1090 assert_eq!(permission(Some(&agent_user), rocket), Some(RepoRole::Read));
1091 assert!(!can(Some(&agent_user), rocket, Capability::Push));
1092 // An outside collaborator with Maintain on one repository: Write
1093 // there, nothing elsewhere, and the run is allowed.
1094 let grants = [
1095 RepoGrant { repo_id: "rep_1".into(), workspace: "acme".into(), role: RepoRole::Maintain, team: None },
1096 RepoGrant { repo_id: "rep_3".into(), workspace: "globex".into(), role: RepoRole::Admin, team: None },
1097 ];
1098 agent_user.workspaces = intersect(&[], "acme");
1099 agent_user.grants = intersect_grants(&grants, "Acme");
1100 assert_eq!(permission(Some(&agent_user), rocket), Some(RepoRole::Write));
1101 assert_eq!(permission(Some(&agent_user), other), None);
1102 assert_eq!(permission(Some(&agent_user), elsewhere), None);
1103 let decision = decide_operation(&agent_user, &tools, "get_issue", Some(&path("acme", "rocket")), true, Some(1));
1104 assert!(decision.allowed, "{}", decision.reason.unwrap_or_default());
1105 // The person, downstream of a runner's credential, carries the same.
1106 let person = as_person(&agent_user).expect("a runner acts as the person");
1107 assert_eq!(permission(Some(&person), rocket), Some(RepoRole::Write));
1108 }
1109
1110 #[test]
1111 fn a_runner_changes_only_its_own_pull_request() {
1112 let scope = scope(K::Implement, Runner);
1113 let user = agent(&["acme"], scope.clone());
1114 let repo = path("acme", "rocket");
1115 let other = decide_operation(&user, &scope, "record_session", Some(&repo), true, Some(8));
1116 assert!(!other.allowed);
1117 assert_eq!(other.rule, "scope:pull");
1118 let own = decide_operation(&user, &scope, "record_session", Some(&repo), true, Some(7));
1119 assert!(own.allowed);
1120 // Reading another is fine.
1121 assert!(
1122 decide_operation(
1123 &user,
1124 &scope,
1125 "get_pull_request",
1126 Some(&repo),
1127 true,
1128 Some(8)
1129 )
1130 .allowed
1131 );
1132 }
1133
1134 #[test]
1135 fn git_matrix() {
1136 let fork = path("pulls", "pul_7");
1137 let upstream = path("acme", "rocket");
1138 let elsewhere = path("acme", "billing");
1139 let implement = scope(K::Implement, Runner);
1140 assert!(decide_git(&implement, &fork, true).allowed);
1141 assert!(decide_git(&implement, &fork, false).allowed);
1142 assert!(decide_git(&implement, &upstream, false).allowed);
1143 assert_eq!(decide_git(&implement, &upstream, true).rule, "git:push");
1144 assert_eq!(decide_git(&implement, &elsewhere, false).rule, "git:read");
1145 let review = scope(K::Review, Runner);
1146 assert!(decide_git(&review, &upstream, false).allowed);
1147 assert!(!decide_git(&review, &upstream, true).allowed);
1148 assert!(!decide_git(&review, &fork, true).allowed);
1149 // A tools token made before run credentials never reaches git.
1150 let old = AgentScope {
1151 repo: upstream.clone(),
1152 operations: vec!["get_issue".to_owned()],
1153 run: None,
1154 };
1155 assert_eq!(decide_git(&old, &upstream, false).rule, "git:not-a-run");
1156 // Nor does an agent's tools token.
1157 assert_eq!(
1158 decide_git(&scope(K::Implement, Tools), &upstream, false).rule,
1159 "git:not-a-run"
1160 );
1161 }
1162
1163 #[test]
1164 fn a_push_moves_only_granted_branches() {
1165 let update = scope(K::Update, Runner);
1166 let repo = path("acme", "rocket");
1167 let refs = |names: &[&str]| {
1168 names
1169 .iter()
1170 .map(|name| (*name).to_owned())
1171 .collect::<Vec<_>>()
1172 };
1173 assert!(decide_refs(&update, &repo, &refs(&["refs/heads/fix-login"])).allowed);
1174 assert_eq!(
1175 decide_refs(&update, &repo, &refs(&["refs/heads/main"])).rule,
1176 "git:ref"
1177 );
1178 assert_eq!(
1179 decide_refs(
1180 &update,
1181 &repo,
1182 &refs(&["refs/heads/fix-login", "refs/tags/v1"])
1183 )
1184 .rule,
1185 "git:ref"
1186 );
1187 let implement = scope(K::Implement, Runner);
1188 assert!(
1189 decide_refs(
1190 &implement,
1191 &path("pulls", "pul_7"),
1192 &refs(&["refs/heads/main"])
1193 )
1194 .allowed
1195 );
1196 }
1197
1198 #[test]
1199 fn a_runner_acts_downstream_as_the_person() {
1200 let user = agent(&["acme"], scope(K::Implement, Runner));
1201 let person = as_person(&user).unwrap();
1202 assert_eq!(person.id, "usr_1");
1203 assert_eq!(person.username, "syntaqx");
1204 assert_eq!(person.kind, PrincipalKind::User);
1205 assert!(person.is_member("acme"));
1206 assert!(person.acting.is_none());
1207 assert_eq!(describe(&user), "g1t on behalf of syntaqx");
1208 // The tools act as the agent.
1209 assert!(as_person(&agent(&["acme"], scope(K::Implement, Tools))).is_none());
1210 }
1211
1212 #[test]
1213 fn scopes_without_a_run_still_parse() {
1214 let old: AgentScope = serde_json::from_str(
1215 r#"{"repo":{"namespace":"acme","name":"rocket"},"operations":["get_issue"]}"#,
1216 )
1217 .unwrap();
1218 assert!(old.run.is_none());
1219 let written = serde_json::to_string(&scope(K::Review, Tools)).unwrap();
1220 assert!(written.contains(r#""use":"tools""#));
1221 assert!(written.contains(r#""kind":"review""#));
1222 let back: AgentScope = serde_json::from_str(&written).unwrap();
1223 assert_eq!(back.run.unwrap().kind, K::Review);
1224 // Only g1t's own runs say so; every other reads as not.
1225 assert!(!written.contains("system"));
1226 assert!(!back_run(&written).system);
1227 let mut own = scope(K::Bump, Runner);
1228 own.run.as_mut().unwrap().system = true;
1229 let written = serde_json::to_string(&own).unwrap();
1230 assert!(written.contains(r#""system":true"#));
1231 assert!(back_run(&written).system);
1232 }
1233
1234 fn back_run(written: &str) -> RunBinding {
1235 serde_json::from_str::<AgentScope>(written).unwrap().run.unwrap()
1236 }
1237}