g1t/crates/contracts/src/credentials.rs

1,157 lines40,780 bytesCodeBlame
1//! Run credentials: the least-privilege tokens a sandbox works with.
2//!
3//! Every sandbox run gets its own tokens, bound to the run, its repository
4//! (and the pull request's fork), what that kind of run needs to do, and an
5//! expiry no later than the run's timeout. Each carries a composite
6//! identity: an agent acting on behalf of the person who started the work.
7//! What it may do is the intersection of the two: the run's scope, and what
8//! that person may do right now.
9//!
10//! The policy lives here, as pure functions, so that identity (which mints
11//! the tokens), the API (which serves REST and MCP) and repos (which serves
12//! git) all enforce the same rules, and so the rules can be tested.
13
14use serde::{Deserialize, Serialize};
15
16use crate::identity::AgentScope;
17use crate::repos::RepoPath;
18use crate::access::{BasePermission, RepoGrant, RepoRole};
19use crate::{Membership, PrincipalKind, Role, User};
20
21/// What a run does, as far as its credentials are concerned. The same names
22/// as [`crate::agents::RunKind`], plus `deploy`, a build of one commit.
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum RunCredentialKind {
26 Implement,
27 Revise,
28 Review,
29 Answer,
30 Update,
31 Plan,
32 Checks,
33 Queue,
34 Mergecheck,
35 Deploy,
36 /// A security update: raising one package's version in its lockfiles
37 /// and pushing that to a branch of its own. Not an agent.
38 Bump,
39}
40
41impl RunCredentialKind {
42 pub const ALL: [RunCredentialKind; 11] = [
43 RunCredentialKind::Implement,
44 RunCredentialKind::Revise,
45 RunCredentialKind::Review,
46 RunCredentialKind::Answer,
47 RunCredentialKind::Update,
48 RunCredentialKind::Plan,
49 RunCredentialKind::Checks,
50 RunCredentialKind::Queue,
51 RunCredentialKind::Mergecheck,
52 RunCredentialKind::Deploy,
53 RunCredentialKind::Bump,
54 ];
55
56 pub fn as_str(self) -> &'static str {
57 match self {
58 RunCredentialKind::Implement => "implement",
59 RunCredentialKind::Revise => "revise",
60 RunCredentialKind::Review => "review",
61 RunCredentialKind::Answer => "answer",
62 RunCredentialKind::Update => "update",
63 RunCredentialKind::Plan => "plan",
64 RunCredentialKind::Checks => "checks",
65 RunCredentialKind::Queue => "queue",
66 RunCredentialKind::Mergecheck => "mergecheck",
67 RunCredentialKind::Deploy => "deploy",
68 RunCredentialKind::Bump => "bump",
69 }
70 }
71
72 /// Whether the run works on one pull request, whose session and
73 /// readiness it reports.
74 fn works_on_a_pull(self) -> bool {
75 matches!(
76 self,
77 RunCredentialKind::Implement
78 | RunCredentialKind::Revise
79 | RunCredentialKind::Answer
80 | RunCredentialKind::Update
81 )
82 }
83}
84
85/// Which part of a sandbox a credential is for.
86#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum CredentialUse {
89 /// g1t's runner: cloning, pushing the result, recording the session.
90 /// It acts as the person downstream, so that what it pushes and records
91 /// is theirs, within the run's scope.
92 Runner,
93 /// The agent's own tools, over MCP. It acts as the agent.
94 Tools,
95}
96
97impl CredentialUse {
98 pub fn as_str(self) -> &'static str {
99 match self {
100 CredentialUse::Runner => "runner",
101 CredentialUse::Tools => "tools",
102 }
103 }
104}
105
106/// A repository a run may push to, and the one branch, if only one.
107#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
108pub struct GitGrant {
109 pub repo: RepoPath,
110 /// Null: any branch. A pull request's fork is its own repository, so
111 /// the whole of it is the pull request's.
112 #[serde(default)]
113 pub branch: Option<String>,
114}
115
116/// What binds an agent's token to one run. Absent on agent tokens made
117/// before run credentials, which keep working for the API only.
118#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "camelCase")]
120pub struct RunBinding {
121 pub kind: RunCredentialKind,
122 #[serde(rename = "use")]
123 pub usage: CredentialUse,
124 /// The agent run, once the sandbox has recorded it.
125 #[serde(default)]
126 pub run_id: Option<String>,
127 /// The pull request the run works on, for the kinds that work on one.
128 #[serde(default)]
129 pub number: Option<u32>,
130 /// The agent's name, such as `g1t`.
131 pub agent: String,
132 /// Repositories it may clone and fetch, besides those it may push to.
133 #[serde(default)]
134 pub read: Vec<RepoPath>,
135 /// Where it may push.
136 #[serde(default)]
137 pub push: Vec<GitGrant>,
138 /// g1t's own run (a security update, an agent g1t put on one): the
139 /// credential belongs to the workspace, and acts on behalf of g1t
140 /// (`system::ID`), so what it does is g1t's, and the pull request g1t
141 /// opened, and its working copy, are its own.
142 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
143 pub system: bool,
144}
145
146/// A person, by id and name.
147#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
148pub struct Principal {
149 pub id: String,
150 pub username: String,
151}
152
153impl From<&User> for Principal {
154 fn from(user: &User) -> Self {
155 Principal {
156 id: user.id.clone(),
157 username: user.username.clone(),
158 }
159 }
160}
161
162/// Set on a [`User`] resolved from an agent's token: the composite
163/// identity, "g1t on behalf of syntaqx", and what it may do.
164#[derive(Clone, Debug, Serialize, Deserialize)]
165#[serde(rename_all = "camelCase")]
166pub struct Acting {
167 /// The token's id, as audit entries name it.
168 pub credential_id: String,
169 pub agent: String,
170 pub on_behalf_of: Principal,
171 pub scope: AgentScope,
172}
173
174impl Acting {
175 pub fn run(&self) -> Option<&RunBinding> {
176 self.scope.run.as_ref()
177 }
178}
179
180/// `create_run_credential`: a token for one sandbox run. It acts as
181/// `agent` on behalf of `on_behalf_of`, can do only what `kind` and `usage`
182/// allow in `repo`, and expires after `ttl_seconds`, which should be the
183/// run's timeout. Returns `CreatedAccessToken`.
184#[derive(Clone, Debug, Serialize, Deserialize)]
185#[serde(rename_all = "camelCase")]
186pub struct CreateRunCredentialArgs {
187 pub on_behalf_of: User,
188 pub repo: RepoPath,
189 pub kind: RunCredentialKind,
190 #[serde(rename = "use")]
191 pub usage: CredentialUse,
192 #[serde(default)]
193 pub number: Option<u32>,
194 #[serde(default)]
195 pub read: Vec<RepoPath>,
196 #[serde(default)]
197 pub push: Vec<GitGrant>,
198 pub ttl_seconds: u64,
199 /// Defaults to `g1t`.
200 #[serde(default)]
201 pub agent: Option<String>,
202}
203
204/// `bind_run_credentials`: ties tokens, named by the SHA-256 of their
205/// text in hex, to the agent run their sandbox recorded. Returns how many.
206#[derive(Clone, Debug, Serialize, Deserialize)]
207#[serde(rename_all = "camelCase")]
208pub struct BindRunCredentialsArgs {
209 pub token_hashes: Vec<String>,
210 pub run_id: String,
211}
212
213/// `revoke_run_credentials`: ends tokens when their sandbox stops, by hash
214/// or by run. Only run credentials are touched, never a token a person
215/// made. Returns how many.
216#[derive(Clone, Debug, Default, Serialize, Deserialize)]
217#[serde(rename_all = "camelCase")]
218pub struct RevokeRunCredentialsArgs {
219 #[serde(default)]
220 pub token_hashes: Vec<String>,
221 #[serde(default)]
222 pub run_id: Option<String>,
223}
224
225// --- Policy --------------------------------------------------------------
226
227/// Operations that only read.
228pub const READ_OPERATIONS: &[&str] = &[
229 "whoami",
230 "list_repos",
231 "get_repo",
232 "list_deleted_repos",
233 "list_collaborators",
234 "get_collaborator_permission",
235 "list_repo_invitations",
236 "list_my_repo_invitations",
237 "list_outside_collaborators",
238 "get_repo_settings",
239 "list_check_names",
240 "get_merge_queue",
241 "recall",
242 "list_issues",
243 "get_issue",
244 "get_plan",
245 "list_labels",
246 "list_pull_requests",
247 "get_pull_request",
248 "read_session",
249 "get_pull_request_changes",
250 "list_events",
251 "get_context",
252 "search_context",
253 "get_entity",
254 "search",
255 "list_workflows",
256 "list_workflow_runs",
257 "get_workflow_run",
258 "get_job_logs",
259 "list_integrations",
260 "get_model_routes",
261 "list_webhooks",
262 "list_webhook_deliveries",
263 "list_actions_secrets",
264 "list_actions_variables",
265 "list_security_alerts",
266];
267
268/// What no agent's token may ever do, whatever its scope says: workspaces,
269/// repositories' settings, members, tokens, billing, integrations,
270/// webhooks, secrets, workflows' controls, merging, and putting more agents
271/// to work.
272pub const NEVER: &[&str] = &[
273 "create_workspace",
274 "delete_workspace",
275 "update_workspace",
276 "transfer_repo",
277 "create_repo",
278 "update_repo",
279 "delete_repo",
280 "list_deleted_repos",
281 "restore_repo",
282 "purge_repo",
283 "rename_repo",
284 "archive_repo",
285 "unarchive_repo",
286 "set_repo_visibility",
287 "rename_branch",
288 "update_repo_settings",
289 "merge_pull_request",
290 "assign_issue",
291 "plan_work",
292 "apply_plan",
293 "import_issue",
294 "list_integrations",
295 "connect_integration",
296 "disconnect_integration",
297 "test_integration",
298 "get_model_routes",
299 "set_model_routes",
300 "list_webhooks",
301 "create_webhook",
302 "update_webhook",
303 "delete_webhook",
304 "ping_webhook",
305 "list_webhook_deliveries",
306 "redeliver_webhook",
307 "dispatch_workflow",
308 "cancel_workflow_run",
309 "rerun_workflow_run",
310 "update_workflow",
311 "list_actions_secrets",
312 "set_actions_secret",
313 "delete_actions_secret",
314 "list_actions_variables",
315 "set_actions_variable",
316 "delete_actions_variable",
317 "list_collaborators",
318 "get_collaborator_permission",
319 "add_collaborator",
320 "update_collaborator",
321 "remove_collaborator",
322 "list_repo_invitations",
323 "revoke_repo_invitation",
324 "list_my_repo_invitations",
325 "accept_repo_invitation",
326 "decline_repo_invitation",
327 "set_base_permission",
328 "list_outside_collaborators",
329 // Dismissing a secret lets it through push protection.
330 "dismiss_security_alert",
331 "reopen_security_alert",
332];
333
334/// Reading what an agent needs to know about its repository.
335const TOOLS_READ: &[&str] = &[
336 "get_repo",
337 "list_issues",
338 "get_issue",
339 "list_labels",
340 "list_pull_requests",
341 "get_pull_request",
342 "get_pull_request_changes",
343 "read_session",
344 "get_merge_queue",
345 "list_events",
346 "recall",
347 "search_context",
348 "get_entity",
349 "search",
350 "list_workflows",
351 "list_workflow_runs",
352 "get_workflow_run",
353 "get_job_logs",
354];
355
356pub fn is_read(operation: &str) -> bool {
357 READ_OPERATIONS.contains(&operation)
358}
359
360/// The API and MCP operations a run of `kind` may use with a credential
361/// for `usage`. Git is separate: see [`decide_git`].
362pub fn operations_for(kind: RunCredentialKind, usage: CredentialUse) -> Vec<&'static str> {
363 use RunCredentialKind as K;
364 let mut operations: Vec<&'static str> = Vec::new();
365 match usage {
366 CredentialUse::Runner => {
367 if kind.works_on_a_pull() {
368 operations.extend(["get_repo", "get_pull_request", "record_session"]);
369 }
370 if kind == K::Implement {
371 operations.push("mark_pull_request_ready");
372 }
373 }
374 CredentialUse::Tools => match kind {
375 K::Implement | K::Revise | K::Answer => {
376 operations.extend(TOOLS_READ.iter().copied());
377 operations.extend([
378 "create_issue",
379 "add_comment",
380 "take_messages",
381 "remember",
382 "message_agent",
383 "answer_message",
384 "get_context",
385 ]);
386 }
387 K::Review => {
388 operations.extend(TOOLS_READ.iter().copied());
389 operations.extend(["add_comment", "review_pull_request", "get_context"]);
390 }
391 K::Plan => {
392 operations.extend(TOOLS_READ.iter().copied());
393 operations.extend(["create_issue", "get_context"]);
394 }
395 K::Update => operations.extend(TOOLS_READ.iter().copied()),
396 K::Checks | K::Queue | K::Mergecheck | K::Deploy | K::Bump => {}
397 },
398 }
399 operations
400}
401
402/// What a run's credential may do, in the scope vocabulary that access
403/// tokens use (see [`crate::scopes`]): the scopes of its operations, and
404/// for a runner, git's. Its operations, its repository and its run still
405/// bound it more tightly than these scopes say.
406pub fn run_scopes(kind: RunCredentialKind, usage: CredentialUse) -> Vec<crate::scopes::Scope> {
407 use crate::scopes::{Scope, normalize, scope_for};
408 let mut scopes: Vec<Scope> = operations_for(kind, usage)
409 .into_iter()
410 .filter_map(scope_for)
411 .collect();
412 if usage == CredentialUse::Runner {
413 scopes.push(Scope::CodeRead);
414 if matches!(
415 kind,
416 RunCredentialKind::Implement
417 | RunCredentialKind::Revise
418 | RunCredentialKind::Answer
419 | RunCredentialKind::Update
420 | RunCredentialKind::Bump
421 ) {
422 scopes.push(Scope::CodeWrite);
423 }
424 }
425 normalize(&mut scopes);
426 scopes
427}
428
429/// Operations that change a pull request, which a runner may do only to
430/// the pull request its run works on.
431const PULL_WRITES: &[&str] = &["record_session", "mark_pull_request_ready"];
432
433/// Whether something was allowed, and the rule that decided it.
434#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
435pub struct Decision {
436 pub allowed: bool,
437 /// A short, stable name: `run:implement/tools`, `never`,
438 /// `scope:repository` and so on. Shown in the audit log.
439 pub rule: String,
440 /// Why it was refused, for the caller.
441 #[serde(default, skip_serializing_if = "Option::is_none")]
442 pub reason: Option<String>,
443}
444
445impl Decision {
446 pub fn allow(rule: impl Into<String>) -> Self {
447 Decision {
448 allowed: true,
449 rule: rule.into(),
450 reason: None,
451 }
452 }
453
454 pub fn deny(rule: impl Into<String>, reason: impl Into<String>) -> Self {
455 Decision {
456 allowed: false,
457 rule: rule.into(),
458 reason: Some(reason.into()),
459 }
460 }
461}
462
463fn same_repo(a: &RepoPath, b: &RepoPath) -> bool {
464 a.namespace.eq_ignore_ascii_case(&b.namespace) && a.name.eq_ignore_ascii_case(&b.name)
465}
466
467fn scope_rule(scope: &AgentScope) -> String {
468 match &scope.run {
469 Some(run) => format!("run:{}/{}", run.kind.as_str(), run.usage.as_str()),
470 None => "agent-token".to_owned(),
471 }
472}
473
474/// Whether `user`, resolved from an agent's token with `scope`, may use
475/// `operation`. `repo` is the repository the call names, if any, and
476/// `needs_repo` whether the operation is about one; `number` the issue or
477/// pull request it names.
478pub fn decide_operation(
479 user: &User,
480 scope: &AgentScope,
481 operation: &str,
482 repo: Option<&RepoPath>,
483 needs_repo: bool,
484 number: Option<u32>,
485) -> Decision {
486 let who = "A g1t agent's token";
487 if NEVER.contains(&operation) {
488 return Decision::deny(
489 "never",
490 format!(
491 "{who} can never use {operation}: settings, members, tokens, billing, integrations, webhooks, secrets and merging are for people."
492 ),
493 );
494 }
495 if !scope.operations.iter().any(|name| name == operation) {
496 return Decision::deny(
497 "scope:operation",
498 format!("{who} for this run cannot use {operation}."),
499 );
500 }
501 if needs_repo && !repo.is_some_and(|asked| same_repo(asked, &scope.repo)) {
502 return Decision::deny(
503 "scope:repository",
504 format!(
505 "{who} works in {}/{} only.",
506 scope.repo.namespace, scope.repo.name
507 ),
508 );
509 }
510 // The intersection: the person it acts for must still be able to work
511 // in the repository's workspace, as a member or with a role on its
512 // repositories. What it may do in the repository itself is their
513 // role there, which services check (`access::can`).
514 if !crate::access::has_access_in(user, &scope.repo.namespace) {
515 return Decision::deny(
516 "on-behalf-of:membership",
517 format!(
518 "The person this agent works for is no longer a member of {}.",
519 scope.repo.namespace
520 ),
521 );
522 }
523 if let Some(run) = &scope.run
524 && run.usage == CredentialUse::Runner
525 && PULL_WRITES.contains(&operation)
526 && run.number.is_some()
527 && number != run.number
528 {
529 return Decision::deny(
530 "scope:pull",
531 format!(
532 "{who} can change pull request #{} only.",
533 run.number.unwrap_or_default()
534 ),
535 );
536 }
537 Decision::allow(scope_rule(scope))
538}
539
540/// Whether a run credential may clone or fetch (`write` false), or push to
541/// (`write` true), the repository at `repo`.
542pub fn decide_git(scope: &AgentScope, repo: &RepoPath, write: bool) -> Decision {
543 let Some(run) = scope
544 .run
545 .as_ref()
546 .filter(|run| run.usage == CredentialUse::Runner)
547 else {
548 return Decision::deny(
549 "git:not-a-run",
550 "A g1t agent's tools token cannot be used with git.",
551 );
552 };
553 let pushable = run.push.iter().any(|grant| same_repo(&grant.repo, repo));
554 if write {
555 return if pushable {
556 Decision::allow(format!("{}:push", scope_rule(scope)))
557 } else {
558 Decision::deny(
559 "git:push",
560 format!(
561 "A {} run cannot push to {}/{}.",
562 run.kind.as_str(),
563 repo.namespace,
564 repo.name
565 ),
566 )
567 };
568 }
569 let readable = pushable
570 || same_repo(&scope.repo, repo)
571 || run.read.iter().any(|path| same_repo(path, repo));
572 if readable {
573 Decision::allow(format!("{}:read", scope_rule(scope)))
574 } else {
575 Decision::deny(
576 "git:read",
577 format!(
578 "A {} run cannot read {}/{}.",
579 run.kind.as_str(),
580 repo.namespace,
581 repo.name
582 ),
583 )
584 }
585}
586
587/// Whether a push to `repo` is limited to certain branches, so that the
588/// refs it moves have to be read and checked with [`decide_refs`].
589pub fn limits_branches(scope: &AgentScope, repo: &RepoPath) -> bool {
590 scope
591 .run
592 .iter()
593 .flat_map(|run| run.push.iter())
594 .any(|grant| same_repo(&grant.repo, repo) && grant.branch.is_some())
595}
596
597/// Whether a push to `repo` may move `refs` (full refs, such as
598/// `refs/heads/main`). Tags are never a run's to move.
599pub fn decide_refs(scope: &AgentScope, repo: &RepoPath, refs: &[String]) -> Decision {
600 let repo_decision = decide_git(scope, repo, true);
601 if !repo_decision.allowed {
602 return repo_decision;
603 }
604 let grants: Vec<&GitGrant> = scope
605 .run
606 .iter()
607 .flat_map(|run| run.push.iter())
608 .filter(|grant| same_repo(&grant.repo, repo))
609 .collect();
610 for git_ref in refs {
611 let Some(branch) = git_ref.strip_prefix("refs/heads/") else {
612 return Decision::deny("git:ref", format!("A run cannot push {git_ref}."));
613 };
614 let allowed = grants
615 .iter()
616 .any(|grant| grant.branch.as_deref().is_none_or(|only| only == branch));
617 if !allowed {
618 return Decision::deny(
619 "git:ref",
620 format!(
621 "A run cannot push to {branch} in {}/{}.",
622 repo.namespace, repo.name
623 ),
624 );
625 }
626 }
627 repo_decision
628}
629
630/// The most an agent may be on a repository, whoever it works for: it
631/// can push, merge and run, never change settings or who has access.
632pub const AGENT_CEILING: RepoRole = RepoRole::Write;
633
634/// The memberships an agent working for `person` has: the run's
635/// workspace, as a member, only if the person is in it now, with the
636/// person's role on its repositories (an owner's Admin included) cut down
637/// to [`AGENT_CEILING`].
638pub fn intersect(person: &[Membership], namespace: &str) -> Vec<Membership> {
639 let namespace = namespace.to_lowercase();
640 person
641 .iter()
642 .filter(|membership| membership.slug == namespace)
643 .map(|membership| {
644 let base = match membership.role {
645 Role::Owner => BasePermission::Admin,
646 Role::Member => membership.base_permission.unwrap_or_default(),
647 };
648 Membership {
649 role: Role::Member,
650 base_permission: Some(match base {
651 BasePermission::Admin => BasePermission::Write,
652 base => base,
653 }),
654 ..membership.clone()
655 }
656 })
657 .collect()
658}
659
660/// The repository grants an agent working for `person` has: those in the
661/// run's workspace, each cut down to [`AGENT_CEILING`].
662pub fn intersect_grants(person: &[RepoGrant], namespace: &str) -> Vec<RepoGrant> {
663 let namespace = namespace.to_lowercase();
664 person
665 .iter()
666 .filter(|grant| grant.workspace == namespace)
667 .map(|grant| RepoGrant {
668 role: grant.role.min(AGENT_CEILING),
669 ..grant.clone()
670 })
671 .collect()
672}
673
674/// Who a runner's credential acts as downstream: the person, with only the
675/// agent's (already intersected) memberships. `None` for anything else.
676pub fn as_person(user: &User) -> Option<User> {
677 let acting = user.acting.as_ref()?;
678 if user.kind != PrincipalKind::Agent {
679 return None;
680 }
681 let run = acting.run()?;
682 if run.usage != CredentialUse::Runner {
683 return None;
684 }
685 Some(User {
686 id: acting.on_behalf_of.id.clone(),
687 username: acting.on_behalf_of.username.clone(),
688 kind: PrincipalKind::User,
689 verified: user.verified,
690 workspaces: user.workspaces.clone(),
691 avatar: None,
692 acting: None,
693 grants: user.grants.clone(),
694 token: None,
695 })
696}
697
698/// How an actor is described: "g1t on behalf of syntaqx".
699pub fn describe(user: &User) -> String {
700 match &user.acting {
701 Some(acting) => format!(
702 "{} on behalf of {}",
703 acting.agent, acting.on_behalf_of.username
704 ),
705 None => user.username.clone(),
706 }
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712
713 #[test]
714 fn a_run_s_scopes_are_never_admin() {
715 for kind in RunCredentialKind::ALL {
716 for usage in [CredentialUse::Runner, CredentialUse::Tools] {
717 let scopes = run_scopes(kind, usage);
718 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{kind:?} {usage:?}: {scopes:?}");
719 }
720 }
721 let review = run_scopes(RunCredentialKind::Review, CredentialUse::Tools);
722 assert!(review.contains(&crate::scopes::Scope::PullRequestsWrite));
723 assert!(!review.contains(&crate::scopes::Scope::CodeWrite));
724 }
725
726 #[test]
727 fn agents_can_search_the_context_hub() {
728 for kind in [RunCredentialKind::Implement, RunCredentialKind::Review, RunCredentialKind::Plan] {
729 let tools = operations_for(kind, CredentialUse::Tools);
730 assert!(tools.contains(&"search_context") && tools.contains(&"get_entity"));
731 }
732 assert!(is_read("search_context") && is_read("get_entity"));
733 }
734
735 #[test]
736 fn agents_can_search_all_of_g1t() {
737 // Site-wide search only reads: every run that reads its repository
738 // may use it, and nothing that never reads gets it.
739 assert!(is_read("search"));
740 assert!(!NEVER.contains(&"search"));
741 for kind in [
742 RunCredentialKind::Implement,
743 RunCredentialKind::Revise,
744 RunCredentialKind::Answer,
745 RunCredentialKind::Review,
746 RunCredentialKind::Plan,
747 RunCredentialKind::Update,
748 ] {
749 let tools = operations_for(kind, CredentialUse::Tools);
750 assert!(tools.contains(&"search"), "{kind:?} should search");
751 // The context hub's search stays its own tool beside it.
752 assert!(tools.contains(&"search_context"), "{kind:?} keeps search_context");
753 }
754 for kind in [RunCredentialKind::Checks, RunCredentialKind::Queue, RunCredentialKind::Mergecheck, RunCredentialKind::Deploy, RunCredentialKind::Bump] {
755 assert!(!operations_for(kind, CredentialUse::Tools).contains(&"search"));
756 }
757 assert!(!operations_for(RunCredentialKind::Implement, CredentialUse::Runner).contains(&"search"));
758 }
759
760 fn path(namespace: &str, name: &str) -> RepoPath {
761 RepoPath {
762 namespace: namespace.to_owned(),
763 name: name.to_owned(),
764 }
765 }
766
767 fn scope(kind: RunCredentialKind, usage: CredentialUse) -> AgentScope {
768 AgentScope {
769 repo: path("acme", "rocket"),
770 operations: operations_for(kind, usage)
771 .into_iter()
772 .map(str::to_owned)
773 .collect(),
774 run: Some(RunBinding {
775 kind,
776 usage,
777 run_id: Some("run_1".to_owned()),
778 number: Some(7),
779 agent: "g1t".to_owned(),
780 system: false,
781 read: vec![path("acme", "rocket")],
782 push: match kind {
783 RunCredentialKind::Implement
784 | RunCredentialKind::Revise
785 | RunCredentialKind::Answer => vec![GitGrant {
786 repo: path("pulls", "pul_7"),
787 branch: None,
788 }],
789 RunCredentialKind::Update => vec![GitGrant {
790 repo: path("acme", "rocket"),
791 branch: Some("fix-login".to_owned()),
792 }],
793 _ => vec![],
794 },
795 }),
796 }
797 }
798
799 fn agent(member_of: &[&str], scope: AgentScope) -> User {
800 User {
801 id: "usr_g1t_agent".to_owned(),
802 username: "g1t".to_owned(),
803 kind: PrincipalKind::Agent,
804 verified: true,
805 workspaces: member_of
806 .iter()
807 .map(|slug| Membership::member(*slug))
808 .collect(),
809 avatar: None,
810 grants: Vec::new(),
811 token: None,
812 acting: Some(Box::new(Acting {
813 credential_id: "tok_1".to_owned(),
814 agent: "g1t".to_owned(),
815 on_behalf_of: Principal {
816 id: "usr_1".to_owned(),
817 username: "syntaqx".to_owned(),
818 },
819 scope,
820 })),
821 }
822 }
823
824 fn op(kind: RunCredentialKind, usage: CredentialUse, operation: &str) -> Decision {
825 let scope = scope(kind, usage);
826 let user = agent(&["acme"], scope.clone());
827 decide_operation(
828 &user,
829 &scope,
830 operation,
831 Some(&path("acme", "rocket")),
832 true,
833 Some(7),
834 )
835 }
836
837 use CredentialUse::{Runner, Tools};
838 use RunCredentialKind as K;
839
840 /// Which operations each kind of run may use through its tools: the
841 /// allowed and denied matrix.
842 #[test]
843 fn tools_matrix() {
844 let cases: [(&str, [bool; 6]); 12] = [
845 // implement revise answer review plan checks
846 ("get_issue", [true, true, true, true, true, false]),
847 ("create_issue", [true, true, true, false, true, false]),
848 ("add_comment", [true, true, true, true, false, false]),
849 (
850 "review_pull_request",
851 [false, false, false, true, false, false],
852 ),
853 ("remember", [true, true, true, false, false, false]),
854 ("take_messages", [true, true, true, false, false, false]),
855 ("record_session", [false, false, false, false, false, false]),
856 (
857 "merge_pull_request",
858 [false, false, false, false, false, false],
859 ),
860 (
861 "update_repo_settings",
862 [false, false, false, false, false, false],
863 ),
864 ("create_webhook", [false, false, false, false, false, false]),
865 (
866 "set_actions_secret",
867 [false, false, false, false, false, false],
868 ),
869 ("assign_issue", [false, false, false, false, false, false]),
870 ];
871 let kinds = [
872 K::Implement,
873 K::Revise,
874 K::Answer,
875 K::Review,
876 K::Plan,
877 K::Checks,
878 ];
879 for (operation, expected) in cases {
880 for (kind, allowed) in kinds.into_iter().zip(expected) {
881 assert_eq!(
882 op(kind, Tools, operation).allowed,
883 allowed,
884 "{operation} by a {} run's tools",
885 kind.as_str()
886 );
887 }
888 }
889 }
890
891 #[test]
892 fn runner_matrix() {
893 assert!(op(K::Implement, Runner, "record_session").allowed);
894 assert!(op(K::Implement, Runner, "mark_pull_request_ready").allowed);
895 assert!(op(K::Revise, Runner, "record_session").allowed);
896 assert!(!op(K::Revise, Runner, "mark_pull_request_ready").allowed);
897 assert!(!op(K::Implement, Runner, "create_issue").allowed);
898 assert!(!op(K::Review, Runner, "record_session").allowed);
899 assert!(!op(K::Checks, Runner, "get_issue").allowed);
900 }
901
902 #[test]
903 fn settings_billing_tokens_and_members_are_never_reachable() {
904 for kind in RunCredentialKind::ALL {
905 for usage in [Runner, Tools] {
906 for operation in NEVER.iter().copied() {
907 let decision = op(kind, usage, operation);
908 assert!(!decision.allowed);
909 assert_eq!(decision.rule, "never");
910 }
911 }
912 }
913 // Even a scope that lists one is refused.
914 let mut wide = scope(K::Implement, Tools);
915 wide.operations.push("merge_pull_request".to_owned());
916 let user = agent(&["acme"], wide.clone());
917 let decision = decide_operation(
918 &user,
919 &wide,
920 "merge_pull_request",
921 Some(&path("acme", "rocket")),
922 true,
923 Some(7),
924 );
925 assert_eq!(decision.rule, "never");
926 }
927
928 #[test]
929 fn another_repository_is_refused() {
930 let scope = scope(K::Implement, Tools);
931 let user = agent(&["acme"], scope.clone());
932 let decision = decide_operation(
933 &user,
934 &scope,
935 "create_issue",
936 Some(&path("acme", "other")),
937 true,
938 None,
939 );
940 assert!(!decision.allowed);
941 assert_eq!(decision.rule, "scope:repository");
942 let decision = decide_operation(&user, &scope, "create_issue", None, true, None);
943 assert_eq!(decision.rule, "scope:repository");
944 // The repository's name is matched without regard to case.
945 let decision = decide_operation(
946 &user,
947 &scope,
948 "create_issue",
949 Some(&path("Acme", "Rocket")),
950 true,
951 None,
952 );
953 assert!(decision.allowed);
954 assert_eq!(decision.rule, "run:implement/tools");
955 }
956
957 #[test]
958 fn the_permission_is_the_intersection_with_the_person() {
959 let scope = scope(K::Implement, Tools);
960 // The person left the workspace: their agent can do nothing there.
961 let user = agent(&[], scope.clone());
962 let decision = decide_operation(
963 &user,
964 &scope,
965 "get_issue",
966 Some(&path("acme", "rocket")),
967 true,
968 Some(1),
969 );
970 assert!(!decision.allowed);
971 assert_eq!(decision.rule, "on-behalf-of:membership");
972 // And an owner's agent is only ever a member.
973 let owner = vec![
974 Membership {
975 slug: "acme".to_owned(),
976 role: Role::Owner,
977 name: None,
978 avatar: None,
979 base_permission: Some(BasePermission::None),
980 },
981 Membership::member("elsewhere"),
982 ];
983 let memberships = intersect(&owner, "Acme");
984 assert_eq!(memberships.len(), 1);
985 assert_eq!(memberships[0].slug, "acme");
986 assert_eq!(memberships[0].role, Role::Member);
987 assert!(intersect(&owner, "nowhere").is_empty());
988 }
989
990 /// An agent gets at most the person's role on the repository, and
991 /// never more than Write; nothing outside the run's workspace.
992 #[test]
993 fn an_agent_has_at_most_its_persons_role() {
994 use crate::access::{Capability, RepoRef, can, permission};
995 let rocket = RepoRef { id: "rep_1", namespace: "acme", private: true };
996 let other = RepoRef { id: "rep_2", namespace: "acme", private: true };
997 let elsewhere = RepoRef { id: "rep_3", namespace: "globex", private: true };
998 let tools = scope(K::Implement, Tools);
999 let scope = scope(K::Implement, Runner);
1000 // An owner's agent: Write, never Admin.
1001 let owner = [Membership { role: Role::Owner, ..Membership::member("acme") }, Membership::member("globex")];
1002 let mut agent_user = agent(&[], scope.clone());
1003 agent_user.workspaces = intersect(&owner, "acme");
1004 assert_eq!(permission(Some(&agent_user), rocket), Some(RepoRole::Write));
1005 assert!(!can(Some(&agent_user), rocket, Capability::ManageSettings));
1006 assert_eq!(permission(Some(&agent_user), elsewhere), None);
1007 // A member whose workspace gives Read: Read, so it cannot push.
1008 let reader = [Membership { base_permission: Some(BasePermission::Read), ..Membership::member("acme") }];
1009 agent_user.workspaces = intersect(&reader, "acme");
1010 assert_eq!(permission(Some(&agent_user), rocket), Some(RepoRole::Read));
1011 assert!(!can(Some(&agent_user), rocket, Capability::Push));
1012 // An outside collaborator with Maintain on one repository: Write
1013 // there, nothing elsewhere, and the run is allowed.
1014 let grants = [
1015 RepoGrant { repo_id: "rep_1".into(), workspace: "acme".into(), role: RepoRole::Maintain },
1016 RepoGrant { repo_id: "rep_3".into(), workspace: "globex".into(), role: RepoRole::Admin },
1017 ];
1018 agent_user.workspaces = intersect(&[], "acme");
1019 agent_user.grants = intersect_grants(&grants, "Acme");
1020 assert_eq!(permission(Some(&agent_user), rocket), Some(RepoRole::Write));
1021 assert_eq!(permission(Some(&agent_user), other), None);
1022 assert_eq!(permission(Some(&agent_user), elsewhere), None);
1023 let decision = decide_operation(&agent_user, &tools, "get_issue", Some(&path("acme", "rocket")), true, Some(1));
1024 assert!(decision.allowed, "{}", decision.reason.unwrap_or_default());
1025 // The person, downstream of a runner's credential, carries the same.
1026 let person = as_person(&agent_user).expect("a runner acts as the person");
1027 assert_eq!(permission(Some(&person), rocket), Some(RepoRole::Write));
1028 }
1029
1030 #[test]
1031 fn a_runner_changes_only_its_own_pull_request() {
1032 let scope = scope(K::Implement, Runner);
1033 let user = agent(&["acme"], scope.clone());
1034 let repo = path("acme", "rocket");
1035 let other = decide_operation(&user, &scope, "record_session", Some(&repo), true, Some(8));
1036 assert!(!other.allowed);
1037 assert_eq!(other.rule, "scope:pull");
1038 let own = decide_operation(&user, &scope, "record_session", Some(&repo), true, Some(7));
1039 assert!(own.allowed);
1040 // Reading another is fine.
1041 assert!(
1042 decide_operation(
1043 &user,
1044 &scope,
1045 "get_pull_request",
1046 Some(&repo),
1047 true,
1048 Some(8)
1049 )
1050 .allowed
1051 );
1052 }
1053
1054 #[test]
1055 fn git_matrix() {
1056 let fork = path("pulls", "pul_7");
1057 let upstream = path("acme", "rocket");
1058 let elsewhere = path("acme", "billing");
1059 let implement = scope(K::Implement, Runner);
1060 assert!(decide_git(&implement, &fork, true).allowed);
1061 assert!(decide_git(&implement, &fork, false).allowed);
1062 assert!(decide_git(&implement, &upstream, false).allowed);
1063 assert_eq!(decide_git(&implement, &upstream, true).rule, "git:push");
1064 assert_eq!(decide_git(&implement, &elsewhere, false).rule, "git:read");
1065 let review = scope(K::Review, Runner);
1066 assert!(decide_git(&review, &upstream, false).allowed);
1067 assert!(!decide_git(&review, &upstream, true).allowed);
1068 assert!(!decide_git(&review, &fork, true).allowed);
1069 // A tools token made before run credentials never reaches git.
1070 let old = AgentScope {
1071 repo: upstream.clone(),
1072 operations: vec!["get_issue".to_owned()],
1073 run: None,
1074 };
1075 assert_eq!(decide_git(&old, &upstream, false).rule, "git:not-a-run");
1076 // Nor does an agent's tools token.
1077 assert_eq!(
1078 decide_git(&scope(K::Implement, Tools), &upstream, false).rule,
1079 "git:not-a-run"
1080 );
1081 }
1082
1083 #[test]
1084 fn a_push_moves_only_granted_branches() {
1085 let update = scope(K::Update, Runner);
1086 let repo = path("acme", "rocket");
1087 let refs = |names: &[&str]| {
1088 names
1089 .iter()
1090 .map(|name| (*name).to_owned())
1091 .collect::<Vec<_>>()
1092 };
1093 assert!(decide_refs(&update, &repo, &refs(&["refs/heads/fix-login"])).allowed);
1094 assert_eq!(
1095 decide_refs(&update, &repo, &refs(&["refs/heads/main"])).rule,
1096 "git:ref"
1097 );
1098 assert_eq!(
1099 decide_refs(
1100 &update,
1101 &repo,
1102 &refs(&["refs/heads/fix-login", "refs/tags/v1"])
1103 )
1104 .rule,
1105 "git:ref"
1106 );
1107 let implement = scope(K::Implement, Runner);
1108 assert!(
1109 decide_refs(
1110 &implement,
1111 &path("pulls", "pul_7"),
1112 &refs(&["refs/heads/main"])
1113 )
1114 .allowed
1115 );
1116 }
1117
1118 #[test]
1119 fn a_runner_acts_downstream_as_the_person() {
1120 let user = agent(&["acme"], scope(K::Implement, Runner));
1121 let person = as_person(&user).unwrap();
1122 assert_eq!(person.id, "usr_1");
1123 assert_eq!(person.username, "syntaqx");
1124 assert_eq!(person.kind, PrincipalKind::User);
1125 assert!(person.is_member("acme"));
1126 assert!(person.acting.is_none());
1127 assert_eq!(describe(&user), "g1t on behalf of syntaqx");
1128 // The tools act as the agent.
1129 assert!(as_person(&agent(&["acme"], scope(K::Implement, Tools))).is_none());
1130 }
1131
1132 #[test]
1133 fn scopes_without_a_run_still_parse() {
1134 let old: AgentScope = serde_json::from_str(
1135 r#"{"repo":{"namespace":"acme","name":"rocket"},"operations":["get_issue"]}"#,
1136 )
1137 .unwrap();
1138 assert!(old.run.is_none());
1139 let written = serde_json::to_string(&scope(K::Review, Tools)).unwrap();
1140 assert!(written.contains(r#""use":"tools""#));
1141 assert!(written.contains(r#""kind":"review""#));
1142 let back: AgentScope = serde_json::from_str(&written).unwrap();
1143 assert_eq!(back.run.unwrap().kind, K::Review);
1144 // Only g1t's own runs say so; every other reads as not.
1145 assert!(!written.contains("system"));
1146 assert!(!back_run(&written).system);
1147 let mut own = scope(K::Bump, Runner);
1148 own.run.as_mut().unwrap().system = true;
1149 let written = serde_json::to_string(&own).unwrap();
1150 assert!(written.contains(r#""system":true"#));
1151 assert!(back_run(&written).system);
1152 }
1153
1154 fn back_run(written: &str) -> RunBinding {
1155 serde_json::from_str::<AgentScope>(written).unwrap().run.unwrap()
1156 }
1157}