flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/contracts/src/credentials.rs

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