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
344 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 runner base keeps its old comment, so the pushed base image still matches its folder1//! 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
143/// Runs `op` for `viewer`: enforcing an agent's scope first, and recording
144/// what happened.
145pub async fn run(
146 op: Op,
147 services: &Services,
148 viewer: &Viewer,
149 input: &Value,
150) -> Result<Outcome<Value>> {
151 let decision = decide(op, services, viewer, input);
152 if let Some(decision) = decision.as_ref().filter(|decision| !decision.allowed) {
153 record(op, services, viewer, input, decision, None).await;
154 return Ok(Outcome::fail(
155 FailureCode::Forbidden,
156 decision.reason.as_deref().unwrap_or("Not allowed."),
157 ));
158 }
159 // A runner's credential acts as the person, within the run's scope.
160 let downstream: Viewer = viewer
161 .as_ref()
162 .and_then(as_person)
163 .or_else(|| viewer.clone());
164 let outcome = op.run(services, &downstream, input).await?;
165 let decision = decision.unwrap_or_else(|| match viewer {
166 Some(user) => Decision::allow(principal_rule(user)),
167 None => Decision::allow("anonymous"),
168 });
169 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 calendar170 // Every person in the answer with the case they chose (people.rs).
171 Ok(match outcome {
172 Outcome::Ok(value) => Outcome::Ok(crate::people::name_people(services, value).await),
173 failed => failed,
174 })
The runner base keeps its old comment, so the pushed base image still matches its folder175}
176
177/// Appends the entry, if this is something the log keeps. A failure to
178/// record is logged, never passed on to the caller.
179async fn record(
180 op: Op,
181 services: &Services,
182 viewer: &Viewer,
183 input: &Value,
184 decision: &Decision,
185 outcome: Option<&Outcome<Value>>,
186) {
187 let Some(user) = viewer.as_ref() else {
188 return;
189 };
190 let actor = AuditActor::of(user);
191 if !actor.records_reads() && is_read(op.name()) {
192 return;
193 }
API: notifications over REST and MCP, with notifications scopes194 // A person's own inbox is nobody else's business.
195 if op.personal() {
196 return;
197 }
The runner base keeps its old comment, so the pushed base image still matches its folder198 let mut entry = NewAuditEntry::new(
199 actor,
200 op.name(),
201 services.audit.surface,
202 target(user, input),
203 decision,
204 services.audit.request_id.clone(),
205 );
206 match outcome {
207 Some(Outcome::Ok(_)) => entry.result = Some("ok".to_owned()),
208 Some(Outcome::Fail(failure)) => {
209 let code = serde_json::to_value(failure.code)
210 .ok()
211 .and_then(|code| code.as_str().map(str::to_owned))
212 .unwrap_or_else(|| "failed".to_owned());
213 // Refused by the service that owns it: still a denial.
214 if matches!(
215 failure.code,
216 FailureCode::Forbidden | FailureCode::Unauthenticated
217 ) {
218 entry.outcome = g1t_contracts::audit::AuditOutcome::Denied;
219 entry.rule = "service".to_owned();
220 }
221 entry.message = Some(failure.message.clone());
222 entry.result = Some(code);
223 }
224 None => entry.result = Some("forbidden".to_owned()),
225 }
226 let recorded: Result<u32> = g1t_kit::call(
227 &services.events,
228 "audit_record",
229 &RecordAuditArgs {
230 entries: vec![entry],
231 },
232 )
233 .await;
234 if let Err(error) = recorded {
235 console_error!("audit entry not recorded for {}: {error}", op.name());
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use g1t_contracts::credentials::{Acting, Principal};
243 use g1t_contracts::identity::AgentScope;
244 use g1t_contracts::scopes::{Scope, TokenAccess};
245 use serde_json::json;
246
247 #[test]
248 fn the_target_comes_from_the_input() {
249 let person = User {
250 id: "usr_1".to_owned(),
251 username: "syntaqx".to_owned(),
252 ..User::default()
253 };
254 let target = target(
255 &person,
256 &json!({ "repo": "Acme/rocket", "number": "12", "path": "src/a.rs" }),
257 );
258 assert_eq!(target.workspace, "acme");
259 assert_eq!(target.repo.as_deref(), Some("Acme/rocket"));
260 assert_eq!(target.number, Some(12));
261 assert_eq!(target.path.as_deref(), Some("src/a.rs"));
262 assert_eq!(
263 super::target(&person, &json!({ "workspace": "Ops" })).workspace,
264 "ops"
265 );
266 }
267
268 fn with_token(scopes: Option<&[Scope]>, legacy: bool) -> User {
269 User {
270 id: "usr_1".to_owned(),
271 username: "syntaqx".to_owned(),
272 token: Some(Box::new(TokenAccess {
273 token_id: "tok_1".to_owned(),
274 scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
275 legacy,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens276 name: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'277 ..TokenAccess::default()
The runner base keeps its old comment, so the pushed base image still matches its folder278 })),
279 ..User::default()
280 }
281 }
282
283 #[test]
284 fn a_session_is_limited_only_by_the_person_s_role() {
285 let person = User { id: "usr_1".to_owned(), ..User::default() };
286 assert!(token_decision(Op::DeleteRepo, &person, &json!({})).is_none());
287 }
288
289 #[test]
290 fn a_legacy_token_still_does_everything() {
291 let legacy = with_token(None, true);
292 for op in Op::ALL {
293 let decision = token_decision(op, &legacy, &json!({ "repo": "acme/rocket" })).unwrap();
294 assert!(decision.allowed, "{}", op.name());
295 assert_eq!(decision.rule, "token:legacy");
296 }
297 }
298
299 #[test]
300 fn a_token_without_the_scope_is_refused_and_told_which() {
301 let reader = with_token(Some(&[Scope::IssuesRead]), false);
302 let input = json!({ "repo": "acme/rocket", "title": "x" });
303 assert!(token_decision(Op::GetIssue, &reader, &input).unwrap().allowed);
304 let refused = token_decision(Op::CreateIssue, &reader, &input).unwrap();
305 assert!(!refused.allowed);
306 assert!(refused.reason.unwrap().contains("issues:write"));
307 assert_eq!(missing_scope(Op::CreateIssue, &Some(reader.clone()), &input), Some(Scope::IssuesWrite));
308 assert_eq!(missing_scope(Op::GetIssue, &Some(reader), &input), None);
309 }
310
311 #[test]
312 fn a_token_reaches_what_its_owner_can() {
313 // Scopes are the only limit a token adds: which workspaces and
314 // repositories it reaches is the owner's, decided downstream.
315 let admin = with_token(Some(&[Scope::RepoAdmin]), false);
316 let moved = token_decision(Op::TransferRepo, &admin, &json!({ "repo": "acme/rocket", "to": "elsewhere" })).unwrap();
317 assert!(moved.allowed);
318 assert_eq!(moved.rule, "token:scope");
319 let full = with_token(None, false);
320 assert!(token_decision(Op::ListWebhooks, &full, &json!({ "workspace": "other" })).unwrap().allowed);
321 }
322
323 #[test]
324 fn an_agent_with_no_repository_is_logged_in_its_run_s_workspace() {
325 let agent = User {
326 kind: PrincipalKind::Agent,
327 acting: Some(Box::new(Acting {
328 credential_id: "tok_1".to_owned(),
329 agent: "g1t".to_owned(),
330 on_behalf_of: Principal::default(),
331 scope: AgentScope {
332 repo: RepoPath {
333 namespace: "acme".to_owned(),
334 name: "rocket".to_owned(),
335 },
336 operations: vec![],
337 run: None,
338 },
339 })),
340 ..User::default()
341 };
342 assert_eq!(target(&agent, &json!({})).workspace, "acme");
343 }
344}

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