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; | |
| 12 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer}; | |
| 13 | use serde_json::Value; | |
| 14 | use worker::{Request, Result, console_error}; | |
| 15 | ||
| 16 | use crate::operations::{Op, Services}; | |
| 17 | ||
| 18 | /// Where a request came in, and the id it is recorded under. | |
| 19 | #[derive(Clone, Debug)] | |
| 20 | pub struct AuditContext { | |
| 21 | pub request_id: String, | |
| 22 | pub surface: Surface, | |
| 23 | } | |
| 24 | ||
| 25 | impl Default for AuditContext { | |
| 26 | fn default() -> Self { | |
| 27 | AuditContext { | |
| 28 | request_id: String::new(), | |
| 29 | surface: Surface::Rest, | |
| 30 | } | |
| 31 | } | |
| 32 | } | |
| 33 | ||
| 34 | impl AuditContext { | |
| 35 | /// Cloudflare's ray id, which also finds the request in Workers logs. | |
| 36 | pub fn of(request: &Request, on_mcp: bool) -> Self { | |
| 37 | let ray = request.headers().get("cf-ray").ok().flatten(); | |
| 38 | AuditContext { | |
| 39 | request_id: ray.unwrap_or_else(|| g1t_contracts::new_id("req", g1t_kit::now_ms())), | |
| 40 | surface: if on_mcp { Surface::Mcp } else { Surface::Rest }, | |
| 41 | } | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | fn repo_path(input: &Value) -> Option<RepoPath> { | |
| 46 | let mut parts = input["repo"].as_str()?.split('/'); | |
| 47 | match (parts.next(), parts.next(), parts.next()) { | |
| 48 | (Some(namespace), Some(name), None) if !namespace.is_empty() && !name.is_empty() => { | |
| 49 | Some(RepoPath { | |
| 50 | namespace: namespace.to_owned(), | |
| 51 | name: name.to_owned(), | |
| 52 | }) | |
| 53 | } | |
| 54 | _ => None, | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | fn number(input: &Value) -> Option<u32> { | |
| 59 | match &input["number"] { | |
| 60 | Value::Number(number) => number.as_u64().and_then(|n| u32::try_from(n).ok()), | |
| 61 | Value::String(digits) => digits.parse().ok(), | |
| 62 | _ => None, | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | fn some_text(input: &Value, key: &str) -> Option<String> { | |
| 67 | input[key] | |
| 68 | .as_str() | |
| 69 | .filter(|value| !value.is_empty()) | |
| 70 | .map(str::to_owned) | |
| 71 | } | |
| 72 | ||
| 73 | /// What an operation named, for its entry. | |
| 74 | pub fn target(user: &User, input: &Value) -> AuditTarget { | |
| 75 | let repo = repo_path(input); | |
| 76 | let workspace = repo | |
| 77 | .as_ref() | |
| 78 | .map(|repo| repo.namespace.clone()) | |
| 79 | .or_else(|| some_text(input, "workspace")) | |
| 80 | .or_else(|| some_text(input, "slug")) | |
| 81 | .or_else(|| { | |
| 82 | user.acting | |
| 83 | .as_ref() | |
| 84 | .map(|acting| acting.scope.repo.namespace.clone()) | |
| 85 | }) | |
| 86 | .unwrap_or_default() | |
| 87 | .to_lowercase(); | |
| 88 | AuditTarget { | |
| 89 | workspace, | |
| 90 | repo: repo.map(|repo| format!("{}/{}", repo.namespace, repo.name)), | |
| 91 | number: number(input), | |
| 92 | git_ref: some_text(input, "ref").or_else(|| some_text(input, "branch")), | |
| 93 | path: some_text(input, "path"), | |
| 94 | } | |
| 95 | } | |
| 96 | ||
| 97 | /// The rule that let a person or a workspace token through: theirs is | |
| 98 | /// decided by the service that owns what they asked about. | |
| 99 | fn principal_rule(user: &User) -> &'static str { | |
| 100 | match user.kind { | |
| 101 | PrincipalKind::Workspace => "workspace-token", | |
| 102 | _ => "person", | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | /// Whether the API refused an agent before anything ran, and why. | |
| 107 | pub fn decide(op: Op, services: &Services, viewer: &Viewer, input: &Value) -> Option<Decision> { | |
| 108 | let user = viewer | |
| 109 | .as_ref() | |
| 110 | .filter(|user| user.kind == PrincipalKind::Agent)?; | |
| 111 | let scope = services.scope.as_ref()?; | |
| 112 | Some(decide_operation( | |
| 113 | user, | |
| 114 | scope, | |
| 115 | op.name(), | |
| 116 | repo_path(input).as_ref(), | |
| 117 | op.needs_repo(), | |
| 118 | number(input), | |
| 119 | )) | |
| 120 | } | |
| 121 | ||
| 122 | /// Runs `op` for `viewer`: enforcing an agent's scope first, and recording | |
| 123 | /// what happened. | |
| 124 | pub async fn run( | |
| 125 | op: Op, | |
| 126 | services: &Services, | |
| 127 | viewer: &Viewer, | |
| 128 | input: &Value, | |
| 129 | ) -> Result<Outcome<Value>> { | |
| 130 | let decision = decide(op, services, viewer, input); | |
| 131 | if let Some(decision) = decision.as_ref().filter(|decision| !decision.allowed) { | |
| 132 | record(op, services, viewer, input, decision, None).await; | |
| 133 | return Ok(Outcome::fail( | |
| 134 | FailureCode::Forbidden, | |
| 135 | decision.reason.as_deref().unwrap_or("Not allowed."), | |
| 136 | )); | |
| 137 | } | |
| 138 | // A runner's credential acts as the person, within the run's scope. | |
| 139 | let downstream: Viewer = viewer | |
| 140 | .as_ref() | |
| 141 | .and_then(as_person) | |
| 142 | .or_else(|| viewer.clone()); | |
| 143 | let outcome = op.run(services, &downstream, input).await?; | |
| 144 | let decision = decision.unwrap_or_else(|| match viewer { | |
| 145 | Some(user) => Decision::allow(principal_rule(user)), | |
| 146 | None => Decision::allow("anonymous"), | |
| 147 | }); | |
| 148 | record(op, services, viewer, input, &decision, Some(&outcome)).await; | |
| 149 | Ok(outcome) | |
| 150 | } | |
| 151 | ||
| 152 | /// Appends the entry, if this is something the log keeps. A failure to | |
| 153 | /// record is logged, never passed on to the caller. | |
| 154 | async fn record( | |
| 155 | op: Op, | |
| 156 | services: &Services, | |
| 157 | viewer: &Viewer, | |
| 158 | input: &Value, | |
| 159 | decision: &Decision, | |
| 160 | outcome: Option<&Outcome<Value>>, | |
| 161 | ) { | |
| 162 | let Some(user) = viewer.as_ref() else { | |
| 163 | return; | |
| 164 | }; | |
| 165 | let actor = AuditActor::of(user); | |
| 166 | if !actor.records_reads() && is_read(op.name()) { | |
| 167 | return; | |
| 168 | } | |
| 169 | let mut entry = NewAuditEntry::new( | |
| 170 | actor, | |
| 171 | op.name(), | |
| 172 | services.audit.surface, | |
| 173 | target(user, input), | |
| 174 | decision, | |
| 175 | services.audit.request_id.clone(), | |
| 176 | ); | |
| 177 | match outcome { | |
| 178 | Some(Outcome::Ok(_)) => entry.result = Some("ok".to_owned()), | |
| 179 | Some(Outcome::Fail(failure)) => { | |
| 180 | let code = serde_json::to_value(failure.code) | |
| 181 | .ok() | |
| 182 | .and_then(|code| code.as_str().map(str::to_owned)) | |
| 183 | .unwrap_or_else(|| "failed".to_owned()); | |
| 184 | // Refused by the service that owns it: still a denial. | |
| 185 | if matches!( | |
| 186 | failure.code, | |
| 187 | FailureCode::Forbidden | FailureCode::Unauthenticated | |
| 188 | ) { | |
| 189 | entry.outcome = g1t_contracts::audit::AuditOutcome::Denied; | |
| 190 | entry.rule = "service".to_owned(); | |
| 191 | } | |
| 192 | entry.message = Some(failure.message.clone()); | |
| 193 | entry.result = Some(code); | |
| 194 | } | |
| 195 | None => entry.result = Some("forbidden".to_owned()), | |
| 196 | } | |
| 197 | let recorded: Result<u32> = g1t_kit::call( | |
| 198 | &services.events, | |
| 199 | "audit_record", | |
| 200 | &RecordAuditArgs { | |
| 201 | entries: vec![entry], | |
| 202 | }, | |
| 203 | ) | |
| 204 | .await; | |
| 205 | if let Err(error) = recorded { | |
| 206 | console_error!("audit entry not recorded for {}: {error}", op.name()); | |
| 207 | } | |
| 208 | } | |
| 209 | ||
| 210 | #[cfg(test)] | |
| 211 | mod tests { | |
| 212 | use super::*; | |
| 213 | use g1t_contracts::credentials::{Acting, Principal}; | |
| 214 | use g1t_contracts::identity::AgentScope; | |
| 215 | use serde_json::json; | |
| 216 | ||
| 217 | #[test] | |
| 218 | fn the_target_comes_from_the_input() { | |
| 219 | let person = User { | |
| 220 | id: "usr_1".to_owned(), | |
| 221 | username: "syntaqx".to_owned(), | |
| 222 | ..User::default() | |
| 223 | }; | |
| 224 | let target = target( | |
| 225 | &person, | |
| 226 | &json!({ "repo": "Acme/rocket", "number": "12", "path": "src/a.rs" }), | |
| 227 | ); | |
| 228 | assert_eq!(target.workspace, "acme"); | |
| 229 | assert_eq!(target.repo.as_deref(), Some("Acme/rocket")); | |
| 230 | assert_eq!(target.number, Some(12)); | |
| 231 | assert_eq!(target.path.as_deref(), Some("src/a.rs")); | |
| 232 | assert_eq!( | |
| 233 | super::target(&person, &json!({ "workspace": "Ops" })).workspace, | |
| 234 | "ops" | |
| 235 | ); | |
| 236 | } | |
| 237 | ||
| 238 | #[test] | |
| 239 | fn an_agent_with_no_repository_is_logged_in_its_run_s_workspace() { | |
| 240 | let agent = User { | |
| 241 | kind: PrincipalKind::Agent, | |
| 242 | acting: Some(Box::new(Acting { | |
| 243 | credential_id: "tok_1".to_owned(), | |
| 244 | agent: "g1t-agent".to_owned(), | |
| 245 | on_behalf_of: Principal::default(), | |
| 246 | scope: AgentScope { | |
| 247 | repo: RepoPath { | |
| 248 | namespace: "acme".to_owned(), | |
| 249 | name: "rocket".to_owned(), | |
| 250 | }, | |
| 251 | operations: vec![], | |
| 252 | run: None, | |
| 253 | }, | |
| 254 | })), | |
| 255 | ..User::default() | |
| 256 | }; | |
| 257 | assert_eq!(target(&agent, &json!({})).workspace, "acme"); | |
| 258 | } | |
| 259 | } |