pr_01m47d15m3e54sn21z27rpy5n9/crates/contracts/src/credentials.rs

1,076 lines37,627 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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