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,418 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 "get_deployment_settings",
275 "list_deleted_repos",
276 "list_collaborators",
277 "get_collaborator_permission",
278 "list_repo_invitations",
279 "list_my_repo_invitations",
280 "list_outside_collaborators",
281 "list_teams",
282 "get_team",
283 "list_team_members",
284 "list_child_teams",
285 "list_team_repos",
286 "list_user_teams",
287 "get_codeowners_errors",
288 "get_repo_settings",
289 "list_check_names",
290 "get_merge_queue",
291 "get_pull_request_merge_options",
292 "list_issues",
293 "get_issue",
294 "list_labels",
295 "list_issue_labels",
296 "list_milestones",
297 "get_milestone",
298 "list_pull_requests",
299 "get_pull_request",
300 "read_session",
301 "list_decisions",
302 "why",
303 "get_pull_request_changes",
304 "list_events",
305 "get_context",
306 "search",
307 "list_workflows",
308 "get_workflow",
309 "list_workflow_runs",
310 "get_workflow_run",
311 "get_job_logs",
312 "get_pending_deployments",
313 "get_workflow_permissions",
314 "get_fork_pr_approval",
315 "get_actions_permissions",
316 "list_commit_statuses",
317 "get_combined_status",
318 "list_check_runs_for_ref",
319 "get_check_run",
320 "list_check_run_annotations",
321 "list_check_suites_for_ref",
322 "get_check_suite",
323 "list_integrations",
324 "list_ai_providers",
325 "get_ask_settings",
326 "list_webhooks",
327 "list_webhook_deliveries",
328 "list_actions_secrets",
329 "list_actions_variables",
330 "list_security_alerts",
331 "list_secret_scanning_alerts",
332 "get_secret_scanning_alert",
333 "list_secret_scanning_locations",
334 "list_bypass_requests",
335 "list_custom_patterns",
336 "list_code_scanning_alerts",
337 "get_code_scanning_alert",
338 "list_code_scanning_analyses",
339 "get_sarif_upload",
340 "list_vulnerability_alerts",
341 "get_vulnerability_alert",
342 "get_dependency_graph",
343 "get_sbom",
344 "compare_dependencies",
345 "get_security_settings",
346 "get_workspace_security_settings",
347 "get_security_overview",
348 "list_security_advisories",
349 "get_security_advisory",
350 "list_vulnerability_reports",
351 "list_notifications",
352 "get_notification_thread",
353 "get_thread_subscription",
354 "get_repo_subscription",
355 "list_watched_repos",
356 "list_pinned_projects",
357];
358
359/// What no run credential may ever do, whatever its scope says:
360/// workspaces, repositories' settings, members, tokens, billing,
361/// integrations, AI providers, webhooks, secrets, workflows' controls and merging.
362pub const NEVER: &[&str] = &[
363 // Billing is people's: runs never spend or change it.
364 "set_budget",
365 "create_workspace",
366 "delete_workspace",
367 "update_workspace",
368 // So is who belongs to a workspace and who owns it.
369 "list_members",
370 "update_member",
371 "remove_member",
372 "transfer_ownership",
373 "leave_workspace",
374 "transfer_repo",
375 "create_repo",
376 "fork_repo",
377 "sync_fork",
378 "detach_fork",
379 "update_repo",
380 "update_project",
381 // Turning a project's deployments on starts paid work; off, takes it down.
382 "update_deployment_settings",
383 "delete_repo",
384 "list_deleted_repos",
385 "restore_repo",
386 "purge_repo",
387 "rename_repo",
388 "archive_repo",
389 "unarchive_repo",
390 "set_repo_visibility",
391 "rename_branch",
392 "delete_branch",
393 "update_repo_settings",
394 "merge_pull_request",
395 // Merging later, as a person asked, is merging; and a merged pull
396 // request's branch is deleted or restored by people.
397 "enable_pull_request_auto_merge",
398 "disable_pull_request_auto_merge",
399 "delete_pull_request_head_branch",
400 "restore_pull_request_head_branch",
401 "import_issue",
402 "list_integrations",
403 "connect_integration",
404 "update_integration",
405 "disconnect_integration",
406 "test_integration",
407 // A workspace's AI: its providers' keys, and what Ask answers with.
408 "list_ai_providers",
409 "add_ai_provider",
410 "update_ai_provider",
411 "remove_ai_provider",
412 "test_ai_provider",
413 "get_ask_settings",
414 "set_ask_settings",
415 "list_webhooks",
416 "create_webhook",
417 "update_webhook",
418 "delete_webhook",
419 "ping_webhook",
420 "list_webhook_deliveries",
421 "redeliver_webhook",
422 "dispatch_workflow",
423 "cancel_workflow_run",
424 "rerun_workflow_run",
425 "update_workflow",
426 // Nor lets runs or deployments through, or changes what holds them.
427 "approve_workflow_run",
428 "review_pending_deployments",
429 "update_environment",
430 "delete_environment",
431 "set_workflow_permissions",
432 "set_fork_pr_approval",
433 "set_actions_access",
434 "set_actions_permissions",
435 "create_repository_dispatch",
436 "set_workspace_workflow_permissions",
437 // An agent never reports checks on its own work, nor asks for them
438 // to run again: what checks say is the integrations' to say.
439 "create_commit_status",
440 "create_check_run",
441 "update_check_run",
442 "rerequest_check_run",
443 "rerequest_check_suite",
444 "list_actions_secrets",
445 "set_actions_secret",
446 "delete_actions_secret",
447 "list_actions_variables",
448 "set_actions_variable",
449 "delete_actions_variable",
450 "list_collaborators",
451 "get_collaborator_permission",
452 "add_collaborator",
453 "update_collaborator",
454 "remove_collaborator",
455 "list_repo_invitations",
456 "revoke_repo_invitation",
457 "list_my_repo_invitations",
458 "accept_repo_invitation",
459 "decline_repo_invitation",
460 // Nor answers a workspace invitation: only the person it is for does.
461 "accept_invitation",
462 "decline_invitation",
463 "set_base_permission",
464 "list_outside_collaborators",
465 // Deploy keys, which let a machine into a repository.
466 "list_deploy_keys",
467 "get_deploy_key",
468 "create_deploy_key",
469 "delete_deploy_key",
470 // Teams: who is in which, and what they reach, is for people.
471 "create_team",
472 "update_team",
473 "delete_team",
474 "set_team_member",
475 "remove_team_member",
476 "set_team_repo",
477 "remove_team_repo",
478 "set_team_review_assignment",
479 // Dismissing a secret lets it through push protection.
480 "dismiss_security_alert",
481 "reopen_security_alert",
482 // Nor any other decision about security: closing or reopening an
483 // alert, pushing past push protection or deciding who may, or changing
484 // what is looked for or when checks fail.
485 "update_secret_scanning_alert",
486 "bypass_push_protection",
487 "review_bypass_request",
488 "create_custom_pattern",
489 "update_custom_pattern",
490 "delete_custom_pattern",
491 "update_code_scanning_alert",
492 "update_vulnerability_alert",
493 "update_security_settings",
494 "update_workspace_security_settings",
495 // A person's own inbox: a run acts as g1t, which has none.
496 "list_notifications",
497 "get_notification_thread",
498 "mark_notifications_read",
499 "mark_thread_read",
500 "mark_thread_done",
501 "save_thread",
502 "snooze_thread",
503 "get_thread_subscription",
504 "set_thread_subscription",
505 "delete_thread_subscription",
506 "get_repo_subscription",
507 "set_repo_subscription",
508 "delete_repo_subscription",
509 "list_watched_repos",
510 // Pins are a person's own, as the inbox is, and so is Getting started.
511 "get_onboarding",
512 "update_onboarding",
513 "list_pinned_projects",
514 "pin_project",
515 "unpin_project",
516 "reorder_pinned_projects",
517 // Deleting packages and deciding who may use them is for people: an
518 // agent publishes its repository's packages and never deletes them.
519 "update_package",
520 "link_package",
521 "unlink_package",
522 "set_package_access",
523 "remove_package_access",
524 "set_package_actions_access",
525 "remove_package_actions_access",
526 "delete_package",
527 "restore_package",
528 "delete_package_version",
529 "restore_package_version",
530 // Sharing an artifact and deleting one for good are for people.
531 "set_workspace_artifact_access",
532 "purge_workspace_artifact",
533];
534
535pub fn is_read(operation: &str) -> bool {
536 READ_OPERATIONS.contains(&operation)
537}
538
539/// The scopes of a session's credential ([`RunCredentialKind::Session`]):
540/// what working on an issue needs, named as an access token's are.
541pub const SESSION_SCOPES: [&str; 8] = [
542 "repo:read",
543 "code:write",
544 "issues:write",
545 "pull_requests:write",
546 "checks:read",
547 "workflows:read",
548 "deployments:read",
549 "observability:read",
550];
551
552/// The API and MCP operations a run of `kind` may use with a credential
553/// for `usage`. Git is separate: see [`decide_git`]. A session's may use
554/// every operation [`SESSION_SCOPES`] allow, and never one in [`NEVER`];
555/// every other run still issued works through git alone, and a
556/// [retired](RunCredentialKind::retired) kind grants nothing. A stored
557/// token keeps the operations it was made with until it expires.
558pub fn operations_for(kind: RunCredentialKind, usage: CredentialUse) -> Vec<&'static str> {
559 use crate::scopes::{NO_SCOPE, OPERATIONS, Scope};
560 if kind != RunCredentialKind::Session || usage != CredentialUse::Runner {
561 return Vec::new();
562 }
563 let held: Vec<Scope> = SESSION_SCOPES.iter().filter_map(|scope| Scope::parse(scope)).collect();
564 NO_SCOPE
565 .iter()
566 .copied()
567 .chain(
568 OPERATIONS
569 .iter()
570 .filter(|(_, needed)| held.iter().any(|scope| scope.includes(*needed)))
571 .map(|(name, _)| *name),
572 )
573 .filter(|name| !NEVER.contains(name))
574 .collect()
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 && !kind.retired() {
588 scopes.push(Scope::CodeRead);
589 if matches!(kind, RunCredentialKind::Bump | RunCredentialKind::Session) {
590 scopes.push(Scope::CodeWrite);
591 }
592 }
593 normalize(&mut scopes);
594 scopes
595}
596
597/// Operations that change a pull request, which a runner may do only to
598/// the pull request its run works on.
599const PULL_WRITES: &[&str] = &["record_session", "record_decision", "mark_pull_request_ready"];
600
601/// Whether something was allowed, and the rule that decided it.
602#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
603pub struct Decision {
604 pub allowed: bool,
605 /// A short, stable name: `run:implement/tools`, `never`,
606 /// `scope:repository` and so on. Shown in the audit log.
607 pub rule: String,
608 /// Why it was refused, for the caller.
609 #[serde(default, skip_serializing_if = "Option::is_none")]
610 pub reason: Option<String>,
611}
612
613impl Decision {
614 pub fn allow(rule: impl Into<String>) -> Self {
615 Decision {
616 allowed: true,
617 rule: rule.into(),
618 reason: None,
619 }
620 }
621
622 pub fn deny(rule: impl Into<String>, reason: impl Into<String>) -> Self {
623 Decision {
624 allowed: false,
625 rule: rule.into(),
626 reason: Some(reason.into()),
627 }
628 }
629}
630
631fn same_repo(a: &RepoPath, b: &RepoPath) -> bool {
632 a.namespace.eq_ignore_ascii_case(&b.namespace) && a.name.eq_ignore_ascii_case(&b.name)
633}
634
635fn scope_rule(scope: &AgentScope) -> String {
636 match &scope.run {
637 Some(run) => format!("run:{}/{}", run.kind.as_str(), run.usage.as_str()),
638 None => "agent-token".to_owned(),
639 }
640}
641
642/// Whether `user`, resolved from a run credential with `scope`, may use
643/// `operation`. `repo` is the repository the call names, if any, and
644/// `needs_repo` whether the operation is about one; `number` the issue or
645/// pull request it names.
646pub fn decide_operation(
647 user: &User,
648 scope: &AgentScope,
649 operation: &str,
650 repo: Option<&RepoPath>,
651 needs_repo: bool,
652 number: Option<u32>,
653) -> Decision {
654 let who = "A run's token";
655 if NEVER.contains(&operation) {
656 return Decision::deny(
657 "never",
658 format!(
659 "{who} can never use {operation}: settings, members, tokens, billing, integrations, webhooks, secrets and merging are for people."
660 ),
661 );
662 }
663 if !scope.operations.iter().any(|name| name == operation) {
664 return Decision::deny(
665 "scope:operation",
666 format!("{who} for this run cannot use {operation}."),
667 );
668 }
669 if needs_repo && !repo.is_some_and(|asked| same_repo(asked, &scope.repo)) {
670 return Decision::deny(
671 "scope:repository",
672 format!(
673 "{who} works in {}/{} only.",
674 scope.repo.namespace, scope.repo.name
675 ),
676 );
677 }
678 // The intersection: the person it acts for must still be able to work
679 // in the repository's workspace, as a member or with a role on its
680 // repositories. What it may do in the repository itself is their
681 // role there, which services check (`access::can`).
682 if !crate::access::has_access_in(user, &scope.repo.namespace) {
683 return Decision::deny(
684 "on-behalf-of:membership",
685 format!(
686 "The person this run works for is no longer a member of {}.",
687 scope.repo.namespace
688 ),
689 );
690 }
691 if let Some(run) = &scope.run
692 && run.usage == CredentialUse::Runner
693 && PULL_WRITES.contains(&operation)
694 && run.number.is_some()
695 && number != run.number
696 {
697 return Decision::deny(
698 "scope:pull",
699 format!(
700 "{who} can change pull request #{} only.",
701 run.number.unwrap_or_default()
702 ),
703 );
704 }
705 Decision::allow(scope_rule(scope))
706}
707
708/// Whether a run credential may clone or fetch (`write` false), or push to
709/// (`write` true), the repository at `repo`.
710pub fn decide_git(scope: &AgentScope, repo: &RepoPath, write: bool) -> Decision {
711 decide_git_at(scope, repo, None, write)
712}
713
714/// [`decide_git`] for the repository at `repo`, which is a pull request's
715/// working copy of `source` when `source` is set. A session's credential
716/// reaches the working copies of its own repository's pull requests as it
717/// does the repository: the pull requests it opens get theirs after the
718/// credential is made.
719pub fn decide_git_at(scope: &AgentScope, repo: &RepoPath, source: Option<&RepoPath>, write: bool) -> Decision {
720 let Some(run) = scope
721 .run
722 .as_ref()
723 .filter(|run| run.usage == CredentialUse::Runner)
724 else {
725 return Decision::deny(
726 "git:not-a-run",
727 "A tools token cannot be used with git.",
728 );
729 };
730 let own_copy = run.kind == RunCredentialKind::Session && source.is_some_and(|source| same_repo(source, &scope.repo));
731 let pushable = own_copy || run.push.iter().any(|grant| same_repo(&grant.repo, repo));
732 if write {
733 return if pushable {
734 Decision::allow(format!("{}:push", scope_rule(scope)))
735 } else {
736 Decision::deny(
737 "git:push",
738 format!(
739 "A {} run cannot push to {}/{}.",
740 run.kind.as_str(),
741 repo.namespace,
742 repo.name
743 ),
744 )
745 };
746 }
747 let readable = pushable
748 || same_repo(&scope.repo, repo)
749 || run.read.iter().any(|path| same_repo(path, repo));
750 if readable {
751 Decision::allow(format!("{}:read", scope_rule(scope)))
752 } else {
753 Decision::deny(
754 "git:read",
755 format!(
756 "A {} run cannot read {}/{}.",
757 run.kind.as_str(),
758 repo.namespace,
759 repo.name
760 ),
761 )
762 }
763}
764
765/// Whether a push to `repo` is limited to certain branches, so that the
766/// refs it moves have to be read and checked with [`decide_refs`].
767pub fn limits_branches(scope: &AgentScope, repo: &RepoPath) -> bool {
768 scope
769 .run
770 .iter()
771 .flat_map(|run| run.push.iter())
772 .any(|grant| same_repo(&grant.repo, repo) && grant.branch.is_some())
773}
774
775/// Whether a push to `repo` may move `refs` (full refs, such as
776/// `refs/heads/main`). Tags are never a run's to move.
777pub fn decide_refs(scope: &AgentScope, repo: &RepoPath, refs: &[String]) -> Decision {
778 let repo_decision = decide_git(scope, repo, true);
779 if !repo_decision.allowed {
780 return repo_decision;
781 }
782 let grants: Vec<&GitGrant> = scope
783 .run
784 .iter()
785 .flat_map(|run| run.push.iter())
786 .filter(|grant| same_repo(&grant.repo, repo))
787 .collect();
788 for git_ref in refs {
789 let Some(branch) = git_ref.strip_prefix("refs/heads/") else {
790 return Decision::deny("git:ref", format!("A run cannot push {git_ref}."));
791 };
792 let allowed = grants
793 .iter()
794 .any(|grant| grant.branch.as_deref().is_none_or(|only| only == branch));
795 if !allowed {
796 return Decision::deny(
797 "git:ref",
798 format!(
799 "A run cannot push to {branch} in {}/{}.",
800 repo.namespace, repo.name
801 ),
802 );
803 }
804 }
805 repo_decision
806}
807
808/// The most an agent may be on a repository, whoever it works for: it
809/// can push, merge and run, never change settings or who has access.
810pub const AGENT_CEILING: RepoRole = RepoRole::Write;
811
812/// The memberships an agent working for `person` has: the run's
813/// workspace, as a member, only if the person is in it now, with the
814/// person's role on its repositories (an owner's Admin included) cut down
815/// to [`AGENT_CEILING`].
816pub fn intersect(person: &[Membership], namespace: &str) -> Vec<Membership> {
817 let namespace = namespace.to_lowercase();
818 person
819 .iter()
820 .filter(|membership| membership.slug == namespace)
821 .map(|membership| {
822 let base = match membership.role {
823 Role::Owner => BasePermission::Admin,
824 Role::Member => membership.base_permission.unwrap_or_default(),
825 };
826 Membership {
827 role: Role::Member,
828 base_permission: Some(match base {
829 BasePermission::Admin => BasePermission::Write,
830 base => base,
831 }),
832 // Billing and security manager are the person's, never
833 // their agent's.
834 org_roles: Vec::new(),
835 ..membership.clone()
836 }
837 })
838 .collect()
839}
840
841/// The repository grants an agent working for `person` has: those in the
842/// run's workspace, each cut down to [`AGENT_CEILING`].
843pub fn intersect_grants(person: &[RepoGrant], namespace: &str) -> Vec<RepoGrant> {
844 let namespace = namespace.to_lowercase();
845 person
846 .iter()
847 .filter(|grant| grant.workspace == namespace)
848 .map(|grant| RepoGrant {
849 role: grant.role.min(AGENT_CEILING),
850 ..grant.clone()
851 })
852 .collect()
853}
854
855/// Who a runner's credential acts as downstream: the person, with only the
856/// agent's (already intersected) memberships. A session's keeps what it
857/// acts as ([`Acting`]), so that what it writes is recorded as g1t's for
858/// that person ([`crate::work::authorship`]), while whose it is (its
859/// session, its claims) and what may be done stay the person's. `None`
860/// for anything else.
861pub fn as_person(user: &User) -> Option<User> {
862 let acting = user.acting.as_ref()?;
863 if user.kind != PrincipalKind::Agent {
864 return None;
865 }
866 let run = acting.run()?;
867 if run.usage != CredentialUse::Runner {
868 return None;
869 }
870 Some(User {
871 id: acting.on_behalf_of.id.clone(),
872 username: acting.on_behalf_of.username.clone(),
873 display_username: None,
874 kind: PrincipalKind::User,
875 verified: user.verified,
876 workspaces: user.workspaces.clone(),
877 avatar: None,
878 acting: (run.kind == RunCredentialKind::Session).then(|| acting.clone()),
879 grants: user.grants.clone(),
880 token: None,
881 held: Vec::new(),
882 })
883}
884
885/// How an actor is described: "g1t on behalf of syntaqx".
886pub fn describe(user: &User) -> String {
887 match &user.acting {
888 Some(acting) => format!(
889 "{} on behalf of {}",
890 acting.agent, acting.on_behalf_of.username
891 ),
892 None => user.username.clone(),
893 }
894}
895
896#[cfg(test)]
897mod tests {
898 use super::*;
899
900 #[test]
901 fn a_run_s_scopes_are_never_admin() {
902 for kind in RunCredentialKind::ALL {
903 for usage in [CredentialUse::Runner, CredentialUse::Tools] {
904 let scopes = run_scopes(kind, usage);
905 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{kind:?} {usage:?}: {scopes:?}");
906 }
907 }
908 use crate::scopes::Scope;
909 let bump = run_scopes(RunCredentialKind::Bump, CredentialUse::Runner);
910 assert!(bump.len() == 2 && bump.contains(&Scope::CodeRead) && bump.contains(&Scope::CodeWrite));
911 assert_eq!(run_scopes(RunCredentialKind::Checks, CredentialUse::Runner), [Scope::CodeRead]);
912 }
913
914 /// The hosted agents' kinds still parse, so stored tokens read, but
915 /// they grant nothing: no operations, no scopes.
916 #[test]
917 fn retired_kinds_parse_and_grant_nothing() {
918 for kind in RunCredentialKind::ALL {
919 let parsed: RunCredentialKind = serde_json::from_value(serde_json::json!(kind.as_str())).unwrap();
920 assert_eq!(parsed, kind);
921 for usage in [CredentialUse::Runner, CredentialUse::Tools] {
922 if kind == RunCredentialKind::Session && usage == CredentialUse::Runner {
923 continue;
924 }
925 assert!(operations_for(kind, usage).is_empty(), "{kind:?} {usage:?}");
926 if kind.retired() {
927 assert!(run_scopes(kind, usage).is_empty(), "{kind:?} {usage:?}");
928 }
929 }
930 }
931 let retired: Vec<&str> = RunCredentialKind::ALL.iter().filter(|kind| kind.retired()).map(|kind| kind.as_str()).collect();
932 assert_eq!(retired, ["implement", "revise", "review", "answer", "update", "plan"]);
933 }
934
935 #[test]
936 fn site_wide_search_only_reads() {
937 assert!(is_read("search"));
938 assert!(!NEVER.contains(&"search"));
939 }
940
941 fn path(namespace: &str, name: &str) -> RepoPath {
942 RepoPath {
943 namespace: namespace.to_owned(),
944 name: name.to_owned(),
945 }
946 }
947
948 /// What a token stored before the hosted agents were retired lists:
949 /// the operations it was made with, which it keeps until it expires.
950 fn stored_operations(kind: RunCredentialKind, usage: CredentialUse) -> Vec<String> {
951 use RunCredentialKind as K;
952 let names: Vec<&str> = match usage {
953 CredentialUse::Runner => match kind {
954 K::Implement => vec!["get_repo", "get_pull_request", "record_session", "mark_pull_request_ready"],
955 K::Revise | K::Answer | K::Update => vec!["get_repo", "get_pull_request", "record_session"],
956 _ => vec![],
957 },
958 CredentialUse::Tools => match kind {
959 K::Implement | K::Revise | K::Answer => vec!["get_issue", "get_pull_request", "create_issue", "add_comment", "remember"],
960 K::Review => vec!["get_issue", "get_pull_request", "add_comment", "review_pull_request"],
961 K::Plan => vec!["get_issue", "get_pull_request", "create_issue"],
962 K::Update => vec!["get_issue", "get_pull_request"],
963 _ => vec![],
964 },
965 };
966 names.into_iter().map(str::to_owned).collect()
967 }
968
969 fn scope(kind: RunCredentialKind, usage: CredentialUse) -> AgentScope {
970 AgentScope {
971 repo: path("acme", "rocket"),
972 operations: stored_operations(kind, usage),
973 run: Some(RunBinding {
974 kind,
975 usage,
976 run_id: Some("run_1".to_owned()),
977 number: Some(7),
978 agent: "g1t".to_owned(),
979 system: false,
980 read: vec![path("acme", "rocket")],
981 push: match kind {
982 RunCredentialKind::Implement
983 | RunCredentialKind::Revise
984 | RunCredentialKind::Answer => vec![GitGrant {
985 repo: path("pulls", "pul_7"),
986 branch: None,
987 }],
988 RunCredentialKind::Update => vec![GitGrant {
989 repo: path("acme", "rocket"),
990 branch: Some("fix-login".to_owned()),
991 }],
992 _ => vec![],
993 },
994 }),
995 }
996 }
997
998 fn agent(member_of: &[&str], scope: AgentScope) -> User {
999 User {
1000 id: "usr_g1t_agent".to_owned(),
1001 username: "g1t".to_owned(),
1002 display_username: None,
1003 kind: PrincipalKind::Agent,
1004 verified: true,
1005 workspaces: member_of
1006 .iter()
1007 .map(|slug| Membership::member(*slug))
1008 .collect(),
1009 avatar: None,
1010 grants: Vec::new(),
1011 token: None,
1012 held: Vec::new(),
1013 acting: Some(Box::new(Acting {
1014 credential_id: "tok_1".to_owned(),
1015 agent: "g1t".to_owned(),
1016 on_behalf_of: Principal {
1017 id: "usr_1".to_owned(),
1018 username: "syntaqx".to_owned(),
1019 },
1020 scope,
1021 })),
1022 }
1023 }
1024
1025 fn op(kind: RunCredentialKind, usage: CredentialUse, operation: &str) -> Decision {
1026 let scope = scope(kind, usage);
1027 let user = agent(&["acme"], scope.clone());
1028 decide_operation(
1029 &user,
1030 &scope,
1031 operation,
1032 Some(&path("acme", "rocket")),
1033 true,
1034 Some(7),
1035 )
1036 }
1037
1038 use CredentialUse::{Runner, Tools};
1039 use RunCredentialKind as K;
1040
1041 /// A stored token may use only what it lists, and never what no run
1042 /// may.
1043 #[test]
1044 fn a_stored_token_uses_only_what_it_lists() {
1045 assert!(op(K::Implement, Tools, "create_issue").allowed);
1046 assert!(op(K::Review, Tools, "review_pull_request").allowed);
1047 assert!(!op(K::Review, Tools, "create_issue").allowed);
1048 assert!(!op(K::Plan, Tools, "add_comment").allowed);
1049 assert!(!op(K::Checks, Tools, "get_issue").allowed);
1050 for operation in ["record_session", "merge_pull_request", "update_repo_settings", "create_webhook", "set_actions_secret"] {
1051 for kind in [K::Implement, K::Revise, K::Answer, K::Review, K::Plan, K::Checks] {
1052 assert!(!op(kind, Tools, operation).allowed, "{operation} by a {} run's tools", kind.as_str());
1053 }
1054 }
1055 }
1056
1057 #[test]
1058 fn runner_matrix() {
1059 assert!(op(K::Implement, Runner, "record_session").allowed);
1060 assert!(op(K::Implement, Runner, "mark_pull_request_ready").allowed);
1061 assert!(op(K::Revise, Runner, "record_session").allowed);
1062 assert!(!op(K::Revise, Runner, "mark_pull_request_ready").allowed);
1063 assert!(!op(K::Implement, Runner, "create_issue").allowed);
1064 assert!(!op(K::Review, Runner, "record_session").allowed);
1065 assert!(!op(K::Checks, Runner, "get_issue").allowed);
1066 }
1067
1068 #[test]
1069 fn settings_billing_tokens_and_members_are_never_reachable() {
1070 for kind in RunCredentialKind::ALL {
1071 for usage in [Runner, Tools] {
1072 for operation in NEVER.iter().copied() {
1073 let decision = op(kind, usage, operation);
1074 assert!(!decision.allowed);
1075 assert_eq!(decision.rule, "never");
1076 }
1077 }
1078 }
1079 // Even a scope that lists one is refused.
1080 let mut wide = scope(K::Implement, Tools);
1081 wide.operations.push("merge_pull_request".to_owned());
1082 let user = agent(&["acme"], wide.clone());
1083 let decision = decide_operation(
1084 &user,
1085 &wide,
1086 "merge_pull_request",
1087 Some(&path("acme", "rocket")),
1088 true,
1089 Some(7),
1090 );
1091 assert_eq!(decision.rule, "never");
1092 }
1093
1094 #[test]
1095 fn another_repository_is_refused() {
1096 let scope = scope(K::Implement, Tools);
1097 let user = agent(&["acme"], scope.clone());
1098 let decision = decide_operation(
1099 &user,
1100 &scope,
1101 "create_issue",
1102 Some(&path("acme", "other")),
1103 true,
1104 None,
1105 );
1106 assert!(!decision.allowed);
1107 assert_eq!(decision.rule, "scope:repository");
1108 let decision = decide_operation(&user, &scope, "create_issue", None, true, None);
1109 assert_eq!(decision.rule, "scope:repository");
1110 // The repository's name is matched without regard to case.
1111 let decision = decide_operation(
1112 &user,
1113 &scope,
1114 "create_issue",
1115 Some(&path("Acme", "Rocket")),
1116 true,
1117 None,
1118 );
1119 assert!(decision.allowed);
1120 assert_eq!(decision.rule, "run:implement/tools");
1121 }
1122
1123 #[test]
1124 fn the_permission_is_the_intersection_with_the_person() {
1125 let scope = scope(K::Implement, Tools);
1126 // The person left the workspace: their agent can do nothing there.
1127 let user = agent(&[], scope.clone());
1128 let decision = decide_operation(
1129 &user,
1130 &scope,
1131 "get_issue",
1132 Some(&path("acme", "rocket")),
1133 true,
1134 Some(1),
1135 );
1136 assert!(!decision.allowed);
1137 assert_eq!(decision.rule, "on-behalf-of:membership");
1138 // And an owner's agent is only ever a member.
1139 let owner = vec![
1140 Membership {
1141 slug: "acme".to_owned(),
1142 role: Role::Owner,
1143 name: None,
1144 avatar: None,
1145 base_permission: Some(BasePermission::None),
1146 team_creation: None,
1147 org_roles: vec![crate::OrgRole::SecurityManager],
1148 privileges: None,
1149 },
1150 Membership::member("elsewhere"),
1151 ];
1152 let memberships = intersect(&owner, "Acme");
1153 assert_eq!(memberships.len(), 1);
1154 assert_eq!(memberships[0].slug, "acme");
1155 assert_eq!(memberships[0].role, Role::Member);
1156 assert!(memberships[0].org_roles.is_empty());
1157 assert!(intersect(&owner, "nowhere").is_empty());
1158 }
1159
1160 /// An agent gets at most the person's role on the repository, and
1161 /// never more than Write; nothing outside the run's workspace.
1162 #[test]
1163 fn an_agent_has_at_most_its_persons_role() {
1164 use crate::access::{Capability, RepoRef, can, permission};
1165 let rocket = RepoRef { id: "rep_1", namespace: "acme", private: true };
1166 let other = RepoRef { id: "rep_2", namespace: "acme", private: true };
1167 let elsewhere = RepoRef { id: "rep_3", namespace: "globex", private: true };
1168 let tools = scope(K::Implement, Tools);
1169 let scope = scope(K::Implement, Runner);
1170 // An owner's agent: Write, never Admin.
1171 let owner = [Membership { role: Role::Owner, ..Membership::member("acme") }, Membership::member("globex")];
1172 let mut agent_user = agent(&[], scope.clone());
1173 agent_user.workspaces = intersect(&owner, "acme");
1174 assert_eq!(permission(Some(&agent_user), rocket), Some(RepoRole::Write));
1175 assert!(!can(Some(&agent_user), rocket, Capability::ManageSettings));
1176 assert_eq!(permission(Some(&agent_user), elsewhere), None);
1177 // A member whose workspace gives Read: Read, so it cannot push.
1178 let reader = [Membership { base_permission: Some(BasePermission::Read), ..Membership::member("acme") }];
1179 agent_user.workspaces = intersect(&reader, "acme");
1180 assert_eq!(permission(Some(&agent_user), rocket), Some(RepoRole::Read));
1181 assert!(!can(Some(&agent_user), rocket, Capability::Push));
1182 // An outside collaborator with Maintain on one repository: Write
1183 // there, nothing elsewhere, and the run is allowed.
1184 let grants = [
1185 RepoGrant { repo_id: "rep_1".into(), workspace: "acme".into(), role: RepoRole::Maintain, team: None },
1186 RepoGrant { repo_id: "rep_3".into(), workspace: "globex".into(), role: RepoRole::Admin, team: None },
1187 ];
1188 agent_user.workspaces = intersect(&[], "acme");
1189 agent_user.grants = intersect_grants(&grants, "Acme");
1190 assert_eq!(permission(Some(&agent_user), rocket), Some(RepoRole::Write));
1191 assert_eq!(permission(Some(&agent_user), other), None);
1192 assert_eq!(permission(Some(&agent_user), elsewhere), None);
1193 let decision = decide_operation(&agent_user, &tools, "get_issue", Some(&path("acme", "rocket")), true, Some(1));
1194 assert!(decision.allowed, "{}", decision.reason.unwrap_or_default());
1195 // The person, downstream of a runner's credential, carries the same.
1196 let person = as_person(&agent_user).expect("a runner acts as the person");
1197 assert_eq!(permission(Some(&person), rocket), Some(RepoRole::Write));
1198 }
1199
1200 #[test]
1201 fn a_runner_changes_only_its_own_pull_request() {
1202 let scope = scope(K::Implement, Runner);
1203 let user = agent(&["acme"], scope.clone());
1204 let repo = path("acme", "rocket");
1205 let other = decide_operation(&user, &scope, "record_session", Some(&repo), true, Some(8));
1206 assert!(!other.allowed);
1207 assert_eq!(other.rule, "scope:pull");
1208 let own = decide_operation(&user, &scope, "record_session", Some(&repo), true, Some(7));
1209 assert!(own.allowed);
1210 // Reading another is fine.
1211 assert!(
1212 decide_operation(
1213 &user,
1214 &scope,
1215 "get_pull_request",
1216 Some(&repo),
1217 true,
1218 Some(8)
1219 )
1220 .allowed
1221 );
1222 }
1223
1224 #[test]
1225 fn git_matrix() {
1226 let fork = path("pulls", "pul_7");
1227 let upstream = path("acme", "rocket");
1228 let elsewhere = path("acme", "billing");
1229 let implement = scope(K::Implement, Runner);
1230 assert!(decide_git(&implement, &fork, true).allowed);
1231 assert!(decide_git(&implement, &fork, false).allowed);
1232 assert!(decide_git(&implement, &upstream, false).allowed);
1233 assert_eq!(decide_git(&implement, &upstream, true).rule, "git:push");
1234 assert_eq!(decide_git(&implement, &elsewhere, false).rule, "git:read");
1235 let review = scope(K::Review, Runner);
1236 assert!(decide_git(&review, &upstream, false).allowed);
1237 assert!(!decide_git(&review, &upstream, true).allowed);
1238 assert!(!decide_git(&review, &fork, true).allowed);
1239 // A tools token made before run credentials never reaches git.
1240 let old = AgentScope {
1241 repo: upstream.clone(),
1242 operations: vec!["get_issue".to_owned()],
1243 run: None,
1244 };
1245 assert_eq!(decide_git(&old, &upstream, false).rule, "git:not-a-run");
1246 // Nor does an agent's tools token.
1247 assert_eq!(
1248 decide_git(&scope(K::Implement, Tools), &upstream, false).rule,
1249 "git:not-a-run"
1250 );
1251 }
1252
1253 #[test]
1254 fn a_push_moves_only_granted_branches() {
1255 let update = scope(K::Update, Runner);
1256 let repo = path("acme", "rocket");
1257 let refs = |names: &[&str]| {
1258 names
1259 .iter()
1260 .map(|name| (*name).to_owned())
1261 .collect::<Vec<_>>()
1262 };
1263 assert!(decide_refs(&update, &repo, &refs(&["refs/heads/fix-login"])).allowed);
1264 assert_eq!(
1265 decide_refs(&update, &repo, &refs(&["refs/heads/main"])).rule,
1266 "git:ref"
1267 );
1268 assert_eq!(
1269 decide_refs(
1270 &update,
1271 &repo,
1272 &refs(&["refs/heads/fix-login", "refs/tags/v1"])
1273 )
1274 .rule,
1275 "git:ref"
1276 );
1277 let implement = scope(K::Implement, Runner);
1278 assert!(
1279 decide_refs(
1280 &implement,
1281 &path("pulls", "pul_7"),
1282 &refs(&["refs/heads/main"])
1283 )
1284 .allowed
1285 );
1286 }
1287
1288 #[test]
1289 fn a_runner_acts_downstream_as_the_person() {
1290 let user = agent(&["acme"], scope(K::Implement, Runner));
1291 let person = as_person(&user).unwrap();
1292 assert_eq!(person.id, "usr_1");
1293 assert_eq!(person.username, "syntaqx");
1294 assert_eq!(person.kind, PrincipalKind::User);
1295 assert!(person.is_member("acme"));
1296 assert!(person.acting.is_none());
1297 assert_eq!(describe(&user), "g1t on behalf of syntaqx");
1298 // The tools act as the agent.
1299 assert!(as_person(&agent(&["acme"], scope(K::Implement, Tools))).is_none());
1300 }
1301
1302 #[test]
1303 fn scopes_without_a_run_still_parse() {
1304 let old: AgentScope = serde_json::from_str(
1305 r#"{"repo":{"namespace":"acme","name":"rocket"},"operations":["get_issue"]}"#,
1306 )
1307 .unwrap();
1308 assert!(old.run.is_none());
1309 let written = serde_json::to_string(&scope(K::Review, Tools)).unwrap();
1310 assert!(written.contains(r#""use":"tools""#));
1311 assert!(written.contains(r#""kind":"review""#));
1312 let back: AgentScope = serde_json::from_str(&written).unwrap();
1313 assert_eq!(back.run.unwrap().kind, K::Review);
1314 // Only g1t's own runs say so; every other reads as not.
1315 assert!(!written.contains("system"));
1316 assert!(!back_run(&written).system);
1317 let mut own = scope(K::Bump, Runner);
1318 own.run.as_mut().unwrap().system = true;
1319 let written = serde_json::to_string(&own).unwrap();
1320 assert!(written.contains(r#""system":true"#));
1321 assert!(back_run(&written).system);
1322 }
1323
1324 fn session_scope() -> AgentScope {
1325 AgentScope {
1326 repo: path("acme", "rocket"),
1327 operations: operations_for(K::Session, Runner).into_iter().map(str::to_owned).collect(),
1328 run: Some(RunBinding {
1329 kind: K::Session,
1330 usage: Runner,
1331 run_id: None,
1332 number: None,
1333 agent: "g1t".to_owned(),
1334 system: false,
1335 read: Vec::new(),
1336 push: vec![GitGrant { repo: path("acme", "rocket"), branch: None }],
1337 }),
1338 }
1339 }
1340
1341 /// A session works on its issue through the API and MCP with what its
1342 /// scopes allow, in its repository, and never with what no run may.
1343 #[test]
1344 fn a_session_does_what_its_scopes_allow_in_its_repository() {
1345 let scope = session_scope();
1346 let user = agent(&["acme"], scope.clone());
1347 let rocket = path("acme", "rocket");
1348 let decide = |operation: &str, repo: &RepoPath| decide_operation(&user, &scope, operation, Some(repo), true, Some(1));
1349 for operation in [
1350 "whoami",
1351 "get_work",
1352 "claim_issue",
1353 "add_comment",
1354 "close_issue",
1355 "create_pull_request",
1356 "review_pull_request",
1357 "mark_pull_request_ready",
1358 "record_session",
1359 "update_session",
1360 "get_workflow_run",
1361 ] {
1362 let decision = decide(operation, &rocket);
1363 assert!(decision.allowed, "{operation}: {:?}", decision.reason);
1364 assert_eq!(decision.rule, "run:session/runner");
1365 }
1366 assert_eq!(decide("add_comment", &path("acme", "billing")).rule, "scope:repository");
1367 for operation in ["merge_pull_request", "update_repo_settings", "set_actions_secret", "list_members", "delete_repo"] {
1368 assert_eq!(decide(operation, &rocket).rule, "never", "{operation}");
1369 }
1370 // What its scopes do not reach: workspaces' settings, deleting.
1371 for operation in ["update_workspace", "delete_package", "create_webhook"] {
1372 assert!(!decide(operation, &rocket).allowed, "{operation}");
1373 }
1374 use crate::scopes::Scope;
1375 let scopes = run_scopes(K::Session, Runner);
1376 assert!(scopes.contains(&Scope::CodeWrite) && scopes.contains(&Scope::IssuesWrite));
1377 assert!(scopes.iter().all(|scope| !scope.dangerous()));
1378 }
1379
1380 /// It pushes to its repository, any branch (a new repository's first
1381 /// version goes to its default branch), and to the working copies of
1382 /// that repository's pull requests, made after it was.
1383 #[test]
1384 fn a_session_pushes_to_its_repository_and_its_pull_requests() {
1385 let session = session_scope();
1386 let rocket = path("acme", "rocket");
1387 let copy = path("pulls", "pul_9");
1388 assert!(decide_git(&session, &rocket, true).allowed);
1389 assert!(!limits_branches(&session, &rocket));
1390 assert!(decide_git_at(&session, &copy, Some(&rocket), true).allowed);
1391 assert!(decide_git_at(&session, &copy, Some(&rocket), false).allowed);
1392 assert_eq!(decide_git(&session, &copy, true).rule, "git:push");
1393 assert_eq!(decide_git_at(&session, &copy, Some(&path("acme", "billing")), true).rule, "git:push");
1394 assert_eq!(decide_git(&session, &path("acme", "billing"), false).rule, "git:read");
1395 // Another kind of run never reaches a pull request's copy this way.
1396 assert!(!decide_git_at(&scope(K::Update, Runner), &copy, Some(&rocket), true).allowed);
1397 }
1398
1399 /// Downstream it is the person, so the session and claims it records
1400 /// are theirs, and it still says it is g1t acting for them, so what it
1401 /// writes is g1t's.
1402 #[test]
1403 fn a_session_acts_downstream_as_the_person_through_g1t() {
1404 let user = agent(&["acme"], session_scope());
1405 let person = as_person(&user).unwrap();
1406 assert_eq!((person.id.as_str(), person.kind), ("usr_1", PrincipalKind::User));
1407 let acting = person.acting.as_ref().expect("it says it is g1t acting for them");
1408 assert_eq!((acting.agent.as_str(), acting.on_behalf_of.username.as_str()), ("g1t", "syntaqx"));
1409 assert_eq!(describe(&person), "g1t on behalf of syntaqx");
1410 let (author, asked) = crate::work::authorship(&person);
1411 assert_eq!(author.username, "g1t");
1412 assert_eq!(asked.map(|asked| asked.username).as_deref(), Some("syntaqx"));
1413 }
1414
1415 fn back_run(written: &str) -> RunBinding {
1416 serde_json::from_str::<AgentScope>(written).unwrap().run.unwrap()
1417 }
1418}