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 API | 1 | //! 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 | ||
| 9 | use g1t_contracts::audit::{AuditActor, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface}; | |
| 10 | use g1t_contracts::credentials::{Decision, as_person, decide_operation, is_read}; | |
| 11 | use g1t_contracts::repos::RepoPath; | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 12 | use g1t_contracts::scopes; |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 13 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer}; |
| 14 | use serde_json::Value; | |
| 15 | use worker::{Request, Result, console_error}; | |
| 16 | ||
| 17 | use crate::operations::{Op, Services}; | |
| 18 | ||
| 19 | /// Where a request came in, and the id it is recorded under. | |
| 20 | #[derive(Clone, Debug)] | |
| 21 | pub struct AuditContext { | |
| 22 | pub request_id: String, | |
| 23 | pub surface: Surface, | |
| 24 | } | |
| 25 | ||
| 26 | impl Default for AuditContext { | |
| 27 | fn default() -> Self { | |
| 28 | AuditContext { | |
| 29 | request_id: String::new(), | |
| 30 | surface: Surface::Rest, | |
| 31 | } | |
| 32 | } | |
| 33 | } | |
| 34 | ||
| 35 | impl 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 | ||
| 46 | fn 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 | ||
| 59 | fn 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 | ||
| 67 | fn 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. | |
| 75 | pub 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. | |
| 100 | fn principal_rule(user: &User) -> &'static str { | |
| 101 | match user.kind { | |
| 102 | PrincipalKind::Workspace => "workspace-token", | |
| 103 | _ => "person", | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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. | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 112 | pub fn decide(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Option<Decision> { |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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. | |
| 130 | fn 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)) | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 133 | } |
| 134 | ||
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 135 | /// The scope a refused call lacked, for the error the caller is sent. |
| 136 | pub 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 | ||
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 143 | /// Runs `op` for `viewer`: enforcing an agent's scope first, and recording |
| 144 | /// what happened. | |
| 145 | pub 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; | |
| 170 | Ok(outcome) | |
| 171 | } | |
| 172 | ||
| 173 | /// Appends the entry, if this is something the log keeps. A failure to | |
| 174 | /// record is logged, never passed on to the caller. | |
| 175 | async fn record( | |
| 176 | op: Op, | |
| 177 | services: &Services, | |
| 178 | viewer: &Viewer, | |
| 179 | input: &Value, | |
| 180 | decision: &Decision, | |
| 181 | outcome: Option<&Outcome<Value>>, | |
| 182 | ) { | |
| 183 | let Some(user) = viewer.as_ref() else { | |
| 184 | return; | |
| 185 | }; | |
| 186 | let actor = AuditActor::of(user); | |
| 187 | if !actor.records_reads() && is_read(op.name()) { | |
| 188 | return; | |
| 189 | } | |
| 190 | let mut entry = NewAuditEntry::new( | |
| 191 | actor, | |
| 192 | op.name(), | |
| 193 | services.audit.surface, | |
| 194 | target(user, input), | |
| 195 | decision, | |
| 196 | services.audit.request_id.clone(), | |
| 197 | ); | |
| 198 | match outcome { | |
| 199 | Some(Outcome::Ok(_)) => entry.result = Some("ok".to_owned()), | |
| 200 | Some(Outcome::Fail(failure)) => { | |
| 201 | let code = serde_json::to_value(failure.code) | |
| 202 | .ok() | |
| 203 | .and_then(|code| code.as_str().map(str::to_owned)) | |
| 204 | .unwrap_or_else(|| "failed".to_owned()); | |
| 205 | // Refused by the service that owns it: still a denial. | |
| 206 | if matches!( | |
| 207 | failure.code, | |
| 208 | FailureCode::Forbidden | FailureCode::Unauthenticated | |
| 209 | ) { | |
| 210 | entry.outcome = g1t_contracts::audit::AuditOutcome::Denied; | |
| 211 | entry.rule = "service".to_owned(); | |
| 212 | } | |
| 213 | entry.message = Some(failure.message.clone()); | |
| 214 | entry.result = Some(code); | |
| 215 | } | |
| 216 | None => entry.result = Some("forbidden".to_owned()), | |
| 217 | } | |
| 218 | let recorded: Result<u32> = g1t_kit::call( | |
| 219 | &services.events, | |
| 220 | "audit_record", | |
| 221 | &RecordAuditArgs { | |
| 222 | entries: vec![entry], | |
| 223 | }, | |
| 224 | ) | |
| 225 | .await; | |
| 226 | if let Err(error) = recorded { | |
| 227 | console_error!("audit entry not recorded for {}: {error}", op.name()); | |
| 228 | } | |
| 229 | } | |
| 230 | ||
| 231 | #[cfg(test)] | |
| 232 | mod tests { | |
| 233 | use super::*; | |
| 234 | use g1t_contracts::credentials::{Acting, Principal}; | |
| 235 | use g1t_contracts::identity::AgentScope; | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 236 | use g1t_contracts::scopes::{Scope, TokenAccess}; |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 237 | use serde_json::json; |
| 238 | ||
| 239 | #[test] | |
| 240 | fn the_target_comes_from_the_input() { | |
| 241 | let person = User { | |
| 242 | id: "usr_1".to_owned(), | |
| 243 | username: "syntaqx".to_owned(), | |
| 244 | ..User::default() | |
| 245 | }; | |
| 246 | let target = target( | |
| 247 | &person, | |
| 248 | &json!({ "repo": "Acme/rocket", "number": "12", "path": "src/a.rs" }), | |
| 249 | ); | |
| 250 | assert_eq!(target.workspace, "acme"); | |
| 251 | assert_eq!(target.repo.as_deref(), Some("Acme/rocket")); | |
| 252 | assert_eq!(target.number, Some(12)); | |
| 253 | assert_eq!(target.path.as_deref(), Some("src/a.rs")); | |
| 254 | assert_eq!( | |
| 255 | super::target(&person, &json!({ "workspace": "Ops" })).workspace, | |
| 256 | "ops" | |
| 257 | ); | |
| 258 | } | |
| 259 | ||
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 260 | fn with_token(scopes: Option<&[Scope]>, legacy: bool) -> User { |
| 261 | User { | |
| 262 | id: "usr_1".to_owned(), | |
| 263 | username: "syntaqx".to_owned(), | |
| 264 | token: Some(Box::new(TokenAccess { | |
| 265 | token_id: "tok_1".to_owned(), | |
| 266 | scopes: scopes.map(|scopes| scopes.iter().map(|scope| scope.as_str().to_owned()).collect()), | |
| 267 | legacy, | |
| 268 | })), | |
| 269 | ..User::default() | |
| 270 | } | |
| 271 | } | |
| 272 | ||
| 273 | #[test] | |
| 274 | fn a_session_is_limited_only_by_the_person_s_role() { | |
| 275 | let person = User { id: "usr_1".to_owned(), ..User::default() }; | |
| 276 | assert!(token_decision(Op::DeleteRepo, &person, &json!({})).is_none()); | |
| 277 | } | |
| 278 | ||
| 279 | #[test] | |
| 280 | fn a_legacy_token_still_does_everything() { | |
| 281 | let legacy = with_token(None, true); | |
| 282 | for op in Op::ALL { | |
| 283 | let decision = token_decision(op, &legacy, &json!({ "repo": "acme/rocket" })).unwrap(); | |
| 284 | assert!(decision.allowed, "{}", op.name()); | |
| 285 | assert_eq!(decision.rule, "token:legacy"); | |
| 286 | } | |
| 287 | } | |
| 288 | ||
| 289 | #[test] | |
| 290 | fn a_token_without_the_scope_is_refused_and_told_which() { | |
| 291 | let reader = with_token(Some(&[Scope::IssuesRead]), false); | |
| 292 | let input = json!({ "repo": "acme/rocket", "title": "x" }); | |
| 293 | assert!(token_decision(Op::GetIssue, &reader, &input).unwrap().allowed); | |
| 294 | let refused = token_decision(Op::CreateIssue, &reader, &input).unwrap(); | |
| 295 | assert!(!refused.allowed); | |
| 296 | assert!(refused.reason.unwrap().contains("issues:write")); | |
| 297 | assert_eq!(missing_scope(Op::CreateIssue, &Some(reader.clone()), &input), Some(Scope::IssuesWrite)); | |
| 298 | assert_eq!(missing_scope(Op::GetIssue, &Some(reader), &input), None); | |
| 299 | } | |
| 300 | ||
| 301 | #[test] | |
| 302 | fn a_token_reaches_what_its_owner_can() { | |
| 303 | // Scopes are the only limit a token adds: which workspaces and | |
| 304 | // repositories it reaches is the owner's, decided downstream. | |
| 305 | let admin = with_token(Some(&[Scope::RepoAdmin]), false); | |
| 306 | let moved = token_decision(Op::TransferRepo, &admin, &json!({ "repo": "acme/rocket", "to": "elsewhere" })).unwrap(); | |
| 307 | assert!(moved.allowed); | |
| 308 | assert_eq!(moved.rule, "token:scope"); | |
| 309 | let full = with_token(None, false); | |
| 310 | assert!(token_decision(Op::ListWebhooks, &full, &json!({ "workspace": "other" })).unwrap().allowed); | |
| 311 | } | |
| 312 | ||
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 313 | #[test] |
| 314 | fn an_agent_with_no_repository_is_logged_in_its_run_s_workspace() { | |
| 315 | let agent = User { | |
| 316 | kind: PrincipalKind::Agent, | |
| 317 | acting: Some(Box::new(Acting { | |
| 318 | credential_id: "tok_1".to_owned(), | |
| 319 | agent: "g1t-agent".to_owned(), | |
| 320 | on_behalf_of: Principal::default(), | |
| 321 | scope: AgentScope { | |
| 322 | repo: RepoPath { | |
| 323 | namespace: "acme".to_owned(), | |
| 324 | name: "rocket".to_owned(), | |
| 325 | }, | |
| 326 | operations: vec![], | |
| 327 | run: None, | |
| 328 | }, | |
| 329 | })), | |
| 330 | ..User::default() | |
| 331 | }; | |
| 332 | assert_eq!(target(&agent, &json!({})).workspace, "acme"); | |
| 333 | } | |
| 334 | } |