Skip to content

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

g1t.sh
392 linesCodeBlameRaw

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.

The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor1//! Least privilege and the audit trail, around every operation.
2//!
3//! An agent's token is checked against its run's scope and the person it
4//! acts for before anything runs (see `g1t_contracts::credentials`); a
5//! runner's credential then acts downstream as that person. Everything an
6//! agent does is recorded, reads included, as is every change a person or
7//! a workspace token makes. Refusals are recorded with their rule.
8
9use g1t_contracts::audit::{AuditActor, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
10use g1t_contracts::credentials::{Decision, as_person, decide_operation, is_read};
11use g1t_contracts::repos::RepoPath;
12use g1t_contracts::scopes;
13use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer};
14use serde_json::Value;
15use worker::{Request, Result, console_error};
16
17use crate::operations::{Op, Services};
18
19/// Where a request came in, and the id it is recorded under.
20#[derive(Clone, Debug)]
21pub struct AuditContext {
22 pub request_id: String,
23 pub surface: Surface,
24}
25
26impl Default for AuditContext {
27 fn default() -> Self {
28 AuditContext {
29 request_id: String::new(),
30 surface: Surface::Rest,
31 }
32 }
33}
34
35impl AuditContext {
36 /// Cloudflare's ray id, which also finds the request in Workers logs.
37 pub fn of(request: &Request, on_mcp: bool) -> Self {
38 let ray = request.headers().get("cf-ray").ok().flatten();
39 AuditContext {
40 request_id: ray.unwrap_or_else(|| g1t_contracts::new_id("req", g1t_kit::now_ms())),
41 surface: if on_mcp { Surface::Mcp } else { Surface::Rest },
42 }
43 }
44}
45
46fn repo_path(input: &Value) -> Option<RepoPath> {
47 let mut parts = input["repo"].as_str()?.split('/');
48 match (parts.next(), parts.next(), parts.next()) {
49 (Some(namespace), Some(name), None) if !namespace.is_empty() && !name.is_empty() => {
50 Some(RepoPath {
51 namespace: namespace.to_owned(),
52 name: name.to_owned(),
53 })
54 }
55 _ => None,
56 }
57}
58
59fn number(input: &Value) -> Option<u32> {
60 match &input["number"] {
61 Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()),
62 Value::String(digits) => digits.parse().ok(),
63 _ => None,
64 }
65}
66
67fn some_text(input: &Value, key: &str) -> Option<String> {
68 input[key]
69 .as_str()
70 .filter(|value| !value.is_empty())
71 .map(str::to_owned)
72}
73
74/// What an operation named, for its entry.
75pub fn target(user: &User, input: &Value) -> AuditTarget {
76 let repo = repo_path(input);
77 let workspace = repo
78 .as_ref()
79 .map(|repo| repo.namespace.clone())
80 .or_else(|| some_text(input, "workspace"))
81 .or_else(|| some_text(input, "slug"))
82 .or_else(|| {
83 user.acting
84 .as_ref()
85 .map(|acting| acting.scope.repo.namespace.clone())
86 })
87 .unwrap_or_default()
88 .to_lowercase();
89 AuditTarget {
90 workspace,
91 repo: repo.map(|repo| format!("{}/{}", repo.namespace, repo.name)),
92 number: number(input),
93 git_ref: some_text(input, "ref").or_else(|| some_text(input, "branch")),
94 path: some_text(input, "path"),
95 }
96}
97
98/// The rule that let a person or a workspace token through: theirs is
99/// decided by the service that owns what they asked about.
100fn principal_rule(user: &User) -> &'static str {
101 match user.kind {
102 PrincipalKind::Workspace => "workspace-token",
103 _ => "person",
104 }
105}
106
107/// Whether the API refused a caller before anything ran, and why: an
108/// agent against its run's scope, or an access token against its scopes.
109/// `None` for a signed-in session, which only the person's role limits.
110/// A token reaches whatever the one it acts as can reach; the service
111/// that owns what was asked about checks that.
112pub fn decide(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Option<Decision> {
113 let user = viewer.as_ref()?;
114 if user.kind == PrincipalKind::Agent {
115 let scope = services.scope.as_ref()?;
116 return Some(decide_operation(
117 user,
118 scope,
119 op.name(),
120 repo_path(input).as_ref(),
121 op.needs_repo(),
122 number(input),
123 ));
124 }
125 token_decision(op, user, input)
126}
127
128/// What an access token's scopes decide about a call; `None` for a
129/// caller without one.
130fn token_decision(op: Op, user: &User, input: &Value) -> Option<Decision> {
131 let access = user.token.as_deref()?;
132 Some(scopes::decide(access, op.name(), input))
133}
134
135/// The scope a refused call lacked, for the error the caller is sent.
136pub fn missing_scope(op: Op, viewer: &Viewer, input: &Value) -> Option<scopes::Scope> {
137 let access = viewer.as_ref()?.token.as_deref()?;
138 scopes::needed(op.name(), input)
139 .into_iter()
140 .find(|scope| !access.allows(*scope))
141}
142
Merge workspace aliases through Actions and tokens: uses: g1t/deploy@v1 resolves to flagon-io/deploy, a token scoped to a repository reaches it under the alias, a g1t/ action is never taken from GitHub, and the Actions release creates g1t's action repositories when they are missing.143/// Whether a decision refuses the call.
144fn refused(decision: &Option<Decision>) -> bool {
145 decision.as_ref().is_some_and(|decision| !decision.allowed)
146}
147
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor148/// Runs `op` for `viewer`: enforcing an agent's scope first, and recording
149/// what happened.
150pub async fn run(
151 op: Op,
152 services: &Services,
153 viewer: &Viewer,
154 input: &Value,
155) -> Result<Outcome<Value>> {
Merge workspace aliases through Actions and tokens: uses: g1t/deploy@v1 resolves to flagon-io/deploy, a token scoped to a repository reaches it under the alias, a g1t/ action is never taken from GitHub, and the Actions release creates g1t's action repositories when they are missing.156 let mut input = std::borrow::Cow::Borrowed(input);
157 let mut decision = decide(op, services, viewer, &input);
158 // A token or an agent's run that reaches one repository knows it by its
159 // workspace's slug. A call that names it under the workspace's alias
160 // (`g1t/deploy` for `flagon-io/deploy`) or an old slug is decided again,
161 // and run, under the slug it resolves to; only a refusal pays for the
162 // lookup, and the call is allowed only as the repository it is.
163 if refused(&decision)
164 && let Some(retargeted) = crate::renamed::retarget(services, &input).await?
165 {
166 let again = decide(op, services, viewer, &retargeted);
167 if !refused(&again) {
168 decision = again;
169 input = std::borrow::Cow::Owned(retargeted);
170 }
171 }
172 let input = input.as_ref();
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor173 if let Some(decision) = decision.as_ref().filter(|decision| !decision.allowed) {
174 record(op, services, viewer, input, decision, None).await;
175 return Ok(Outcome::fail(
176 FailureCode::Forbidden,
177 decision.reason.as_deref().unwrap_or("Not allowed."),
178 ));
179 }
180 // A runner's credential acts as the person, within the run's scope.
181 let downstream: Viewer = viewer
182 .as_ref()
183 .and_then(as_person)
184 .or_else(|| viewer.clone());
185 let outcome = op.run(services, &downstream, input).await?;
186 let decision = decision.unwrap_or_else(|| match viewer {
187 Some(user) => Decision::allow(principal_rule(user)),
188 None => Decision::allow("anonymous"),
189 });
190 record(op, services, viewer, input, &decision, Some(&outcome)).await;
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar191 // Every person in the answer with the case they chose (people.rs).
192 Ok(match outcome {
193 Outcome::Ok(value) => Outcome::Ok(crate::people::name_people(services, value).await),
194 failed => failed,
195 })
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor196}
197
198/// Appends the entry, if this is something the log keeps. A failure to
199/// record is logged, never passed on to the caller.
200async fn record(
201 op: Op,
202 services: &Services,
203 viewer: &Viewer,
204 input: &Value,
205 decision: &Decision,
206 outcome: Option<&Outcome<Value>>,
207) {
208 let Some(user) = viewer.as_ref() else {
209 return;
210 };
211 let actor = AuditActor::of(user);
212 if !actor.records_reads() && is_read(op.name()) {
213 return;
214 }
API: notifications over REST and MCP, with notifications scopes215 // A person's own inbox is nobody else's business.
216 if op.personal() {
217 return;
218 }
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor219 let mut entry = NewAuditEntry::new(
220 actor,
221 op.name(),
222 services.audit.surface,
223 target(user, input),
224 decision,
225 services.audit.request_id.clone(),
226 );
227 match outcome {
228 Some(Outcome::Ok(_)) => entry.result = Some("ok".to_owned()),
229 Some(Outcome::Fail(failure)) => {
230 let code = serde_json::to_value(failure.code)
231 .ok()
232 .and_then(|code| code.as_str().map(str::to_owned))
233 .unwrap_or_else(|| "failed".to_owned());
234 // Refused by the service that owns it: still a denial.
235 if matches!(
236 failure.code,
237 FailureCode::Forbidden | FailureCode::Unauthenticated
238 ) {
239 entry.outcome = g1t_contracts::audit::AuditOutcome::Denied;
240 entry.rule = "service".to_owned();
241 }
242 entry.message = Some(failure.message.clone());
243 entry.result = Some(code);
244 }
245 None => entry.result = Some("forbidden".to_owned()),
246 }
247 let recorded: Result<u32> = g1t_kit::call(
248 &services.events,
249 "audit_record",
250 &RecordAuditArgs {
251 entries: vec![entry],
252 },
253 )
254 .await;
255 if let Err(error) = recorded {
256 console_error!("audit entry not recorded for {}: {error}", op.name());
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263 use g1t_contracts::credentials::{Acting, Principal};
264 use g1t_contracts::identity::AgentScope;
265 use g1t_contracts::scopes::{Scope, TokenAccess};
266 use serde_json::json;
267
268 #[test]
269 fn the_target_comes_from_the_input() {
270 let person = User {
271 id: "usr_1".to_owned(),
272 username: "syntaqx".to_owned(),
273 ..User::default()
274 };
275 let target = target(
276 &person,
277 &json!({ "repo": "Acme/rocket", "number": "12", "path": "src/a.rs" }),
278 );
279 assert_eq!(target.workspace, "acme");
280 assert_eq!(target.repo.as_deref(), Some("Acme/rocket"));
281 assert_eq!(target.number, Some(12));
282 assert_eq!(target.path.as_deref(), Some("src/a.rs"));
283 assert_eq!(
284 super::target(&person, &json!({ "workspace": "Ops" })).workspace,
285 "ops"
286 );
287 }
288
289 fn with_token(scopes: Option<&[Scope]>, legacy: bool) -> User {
290 User {
291 id: "usr_1".to_owned(),
292 username: "syntaqx".to_owned(),
293 token: Some(Box::new(TokenAccess {
294 token_id: "tok_1".to_owned(),
295 scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
296 legacy,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens297 name: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'298 ..TokenAccess::default()
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor299 })),
300 ..User::default()
301 }
302 }
303
Merge workspace aliases through Actions and tokens: uses: g1t/deploy@v1 resolves to flagon-io/deploy, a token scoped to a repository reaches it under the alias, a g1t/ action is never taken from GitHub, and the Actions release creates g1t's action repositories when they are missing.304 /// A workflow job's token reaches its own repository, by its
305 /// workspace's slug; named under the workspace's alias, the call is
306 /// refused as written and decided again under the slug.
307 #[test]
308 fn a_repositorys_token_reaches_it_under_its_workspaces_alias() {
309 let job = User {
310 id: "usr_1".to_owned(),
311 username: "syntaqx".to_owned(),
312 token: Some(Box::new(TokenAccess {
313 token_id: "tok_job".to_owned(),
314 scopes: Some(vec![Scope::RepoRead.as_str().to_owned()]),
315 repo: Some("flagon-io/deploy".to_owned()),
316 ..TokenAccess::default()
317 })),
318 ..User::default()
319 };
320 let aliased = json!({ "repo": "g1t/deploy" });
321 let first = token_decision(Op::GetRepo, &job, &aliased);
322 assert!(refused(&first));
323 let retargeted = crate::renamed::rewrite(&aliased, "g1t", "flagon-io");
324 assert!(!refused(&token_decision(Op::GetRepo, &job, &retargeted)));
325 // Another repository of the same workspace stays out of reach.
326 let other = crate::renamed::rewrite(&json!({ "repo": "g1t/internal" }), "g1t", "flagon-io");
327 assert!(refused(&token_decision(Op::GetRepo, &job, &other)));
328 assert!(!refused(&None));
329 }
330
The sidebar asks billing whether it is on, not for the account, so no page waits on the card processor331 #[test]
332 fn a_session_is_limited_only_by_the_person_s_role() {
333 let person = User { id: "usr_1".to_owned(), ..User::default() };
334 assert!(token_decision(Op::DeleteRepo, &person, &json!({})).is_none());
335 }
336
337 #[test]
338 fn a_legacy_token_still_does_everything() {
339 let legacy = with_token(None, true);
340 for op in Op::ALL {
341 let decision = token_decision(op, &legacy, &json!({ "repo": "acme/rocket" })).unwrap();
342 assert!(decision.allowed, "{}", op.name());
343 assert_eq!(decision.rule, "token:legacy");
344 }
345 }
346
347 #[test]
348 fn a_token_without_the_scope_is_refused_and_told_which() {
349 let reader = with_token(Some(&[Scope::IssuesRead]), false);
350 let input = json!({ "repo": "acme/rocket", "title": "x" });
351 assert!(token_decision(Op::GetIssue, &reader, &input).unwrap().allowed);
352 let refused = token_decision(Op::CreateIssue, &reader, &input).unwrap();
353 assert!(!refused.allowed);
354 assert!(refused.reason.unwrap().contains("issues:write"));
355 assert_eq!(missing_scope(Op::CreateIssue, &Some(reader.clone()), &input), Some(Scope::IssuesWrite));
356 assert_eq!(missing_scope(Op::GetIssue, &Some(reader), &input), None);
357 }
358
359 #[test]
360 fn a_token_reaches_what_its_owner_can() {
361 // Scopes are the only limit a token adds: which workspaces and
362 // repositories it reaches is the owner's, decided downstream.
363 let admin = with_token(Some(&[Scope::RepoAdmin]), false);
364 let moved = token_decision(Op::TransferRepo, &admin, &json!({ "repo": "acme/rocket", "to": "elsewhere" })).unwrap();
365 assert!(moved.allowed);
366 assert_eq!(moved.rule, "token:scope");
367 let full = with_token(None, false);
368 assert!(token_decision(Op::ListWebhooks, &full, &json!({ "workspace": "other" })).unwrap().allowed);
369 }
370
371 #[test]
372 fn an_agent_with_no_repository_is_logged_in_its_run_s_workspace() {
373 let agent = User {
374 kind: PrincipalKind::Agent,
375 acting: Some(Box::new(Acting {
376 credential_id: "tok_1".to_owned(),
377 agent: "g1t".to_owned(),
378 on_behalf_of: Principal::default(),
379 scope: AgentScope {
380 repo: RepoPath {
381 namespace: "acme".to_owned(),
382 name: "rocket".to_owned(),
383 },
384 operations: vec![],
385 run: None,
386 },
387 })),
388 ..User::default()
389 };
390 assert_eq!(target(&agent, &json!({})).workspace, "acme");
391 }
392}

This file's history is long; its oldest lines are credited to the oldest commit read.