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