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.

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