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
435 linesCodeBlameRaw
1//! Guardrails: how long each kind of run may take, as a workspace's
2//! defaults and each project's overrides. The runner service reads what a
3//! run gets (`run_guardrails`) and enforces it; this service keeps the
4//! settings and ends a run that reached a cap (`halt_run`, from
5//! `report_run`).
6//!
7//! Network rules (restricting the network, registries, allowed and
8//! workflow-only domains), command rules, deny patterns and cost caps are
9//! gone: levels saved with them still read, and they are dropped when
10//! saved again. The `guardrails` table keeps each level as JSON, so
11//! nothing in it needed to change.
12//!
13//! See `g1t_contracts::guardrails` for the rules and how levels merge.
14
15use g1t_contracts::access::{self, Capability};
16use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
17use g1t_contracts::agents::RunStatus;
18use g1t_contracts::guardrails::*;
19use g1t_contracts::repos::{Repo, RepoPath};
20use g1t_contracts::time::rfc3339;
21use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, Viewer, new_id};
22use g1t_kit::now_ms;
23use serde::Deserialize;
24use worker::Result;
25use worker::wasm_bindgen::JsValue;
26
27use crate::Work;
28use crate::lifecycle::{POLICY_ACTOR_ID, POLICY_ACTOR_NAME};
29use crate::runs::member_of;
30
31/// Statements that move a renamed workspace's guardrails to its new slug.
32/// Run with the new slug as `?1` and the old as `?2`.
33pub(crate) const RENAMED: &[&str] = &[
34 "UPDATE guardrails SET scope_key = ?1 WHERE scope = 'workspace' AND scope_key = ?2",
35 "UPDATE guardrails SET workspace = ?1 WHERE workspace = ?2",
36];
37
38/// A repository transferred: its own guardrails follow it to the new
39/// workspace (see `g1t_kit::transfer`; `?3` the workspace now, `?4` the one
40/// before, `?5` the repository's id).
41pub(crate) const TRANSFERRED: &[&str] = &[
42 "UPDATE guardrails SET workspace = ?3 WHERE scope <> 'workspace' AND scope_key = ?5 AND workspace = ?4",
43];
44
45#[derive(Deserialize)]
46struct SettingsRow {
47 settings: String,
48 updated_by: String,
49 updated_at: String,
50}
51
52impl From<SettingsRow> for GuardrailSettings {
53 fn from(row: SettingsRow) -> Self {
54 let mut settings: GuardrailSettings = serde_json::from_str(&row.settings).unwrap_or_default();
55 settings.updated_by = Some(row.updated_by);
56 settings.updated_at = Some(row.updated_at);
57 settings
58 }
59}
60
61/// Whether `actor` is a verified person, not a token or an agent.
62fn is_person(actor: &User) -> bool {
63 actor.verified && actor.kind == PrincipalKind::User
64}
65
66/// What a run stopped for looking like mining says, everywhere it shows.
67pub(crate) const ABUSE_MESSAGE: &str = "Stopped: unusual CPU use; contact support if this was a real job.";
68
69/// What a halted run is told, and what its pull request says.
70fn halt_message(halt: Halt) -> &'static str {
71 match halt {
72 Halt::Budget => "Stopped: it reached its cost cap.",
73 Halt::Time => "Stopped: it reached its time cap.",
74 Halt::Abuse => ABUSE_MESSAGE,
75 }
76}
77
78impl Work {
79 async fn guardrail_level(&self, scope: &str, key: &str) -> Result<GuardrailSettings> {
80 Ok(self
81 .db
82 .prepare("SELECT settings, updated_by, updated_at FROM guardrails WHERE scope = ? AND scope_key = ?")
83 .bind(&[scope.into(), key.into()])?
84 .first::<SettingsRow>(None)
85 .await?
86 .map(GuardrailSettings::from)
87 .unwrap_or_default())
88 }
89
90 /// The project at `path`, which must be in `workspace`.
91 async fn guarded_repo(&self, path: &RepoPath, viewer: &Viewer, workspace: &str) -> Result<Outcome<Repo>> {
92 Ok(match self.repo(path, viewer).await? {
93 Outcome::Ok(repo) if repo.namespace.to_lowercase() == workspace => Outcome::Ok(repo),
94 Outcome::Ok(_) => Outcome::fail(FailureCode::Invalid, "That project is in another workspace."),
95 Outcome::Fail(failure) => Outcome::Fail(failure),
96 })
97 }
98
99 async fn guardrails_view(&self, workspace: &str, repo: Option<&Repo>) -> Result<GuardrailsView> {
100 let level = self.guardrail_level("workspace", workspace).await?;
101 let project = match repo {
102 Some(repo) => Some(self.guardrail_level("project", &repo.id).await?),
103 None => None,
104 };
105 Ok(GuardrailsView::new(level, project))
106 }
107
108 pub(crate) async fn get_guardrails(&self, a: GetGuardrailsArgs) -> Result<Outcome<GuardrailsView>> {
109 let workspace = a.workspace.to_lowercase();
110 let member = a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&workspace));
111 if !member && a.repo.is_none() {
112 return Ok(Outcome::fail(
113 FailureCode::Forbidden,
114 "Guardrails are for members of the workspace.",
115 ));
116 }
117 let repo = match &a.repo {
118 Some(path) => match self.guarded_repo(path, &a.viewer, &workspace).await? {
119 Outcome::Ok(repo) => Some(repo),
120 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
121 },
122 None => None,
123 };
124 // Members see them; so does anyone else who may change the
125 // project's, such as an outside collaborator with Admin.
126 if !member
127 && !repo
128 .as_ref()
129 .is_some_and(|repo| access::can(a.viewer.as_ref(), repo, Capability::ManageProtection))
130 {
131 return Ok(Outcome::fail(
132 FailureCode::Forbidden,
133 "Guardrails are for members of the workspace.",
134 ));
135 }
136 Ok(Outcome::Ok(self.guardrails_view(&workspace, repo.as_ref()).await?))
137 }
138
139 pub(crate) async fn update_guardrails(&self, a: UpdateGuardrailsArgs) -> Result<Outcome<GuardrailsView>> {
140 let workspace = a.workspace.to_lowercase();
141 let viewer = Some(a.actor.clone());
142 let repo = match &a.repo {
143 Some(path) => match self.guarded_repo(path, &viewer, &workspace).await? {
144 Outcome::Ok(repo) => Some(repo),
145 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
146 },
147 None => None,
148 };
149 // A project's guardrails go with its branch protection (Admin);
150 // the defaults every project inherits are the owners'.
151 let allowed = is_person(&a.actor)
152 && match &repo {
153 Some(repo) => access::can(Some(&a.actor), repo, Capability::ManageProtection),
154 None => a.actor.role_in(&workspace) == Some(Role::Owner),
155 };
156 if !allowed {
157 let message = match &repo {
158 Some(repo) => access::needs(
159 Capability::ManageProtection,
160 &format!("{}/{}", repo.namespace, repo.name),
161 ),
162 None => "Only owners of the workspace can change its guardrails.".to_owned(),
163 };
164 return Ok(Outcome::fail(FailureCode::Forbidden, message));
165 }
166 let mut settings = match validate(a.settings) {
167 Ok(settings) => settings,
168 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
169 };
170 settings.updated_by = None;
171 settings.updated_at = None;
172 let (scope, key) = match &repo {
173 Some(repo) => ("project", repo.id.clone()),
174 None => ("workspace", workspace.clone()),
175 };
176 let before = self.guardrail_level(scope, &key).await?;
177 self.db
178 .prepare(
179 "INSERT INTO guardrails (scope, scope_key, workspace, settings, updated_by, updated_at)
180 VALUES (?, ?, ?, ?, ?, ?)
181 ON CONFLICT (scope, scope_key) DO UPDATE SET
182 settings = excluded.settings,
183 updated_by = excluded.updated_by,
184 updated_at = excluded.updated_at",
185 )
186 .bind(&[
187 scope.into(),
188 key.into(),
189 workspace.as_str().into(),
190 serde_json::to_string(&settings)?.into(),
191 a.actor.username.as_str().into(),
192 rfc3339(now_ms()).into(),
193 ])?
194 .run()
195 .await?;
196 let full = repo.as_ref().map(|repo| format!("{}/{}", repo.namespace, repo.name));
197 self.audit_guardrails(&a.actor, &workspace, full, &guardrails_change(&before, &settings)).await;
198 Ok(Outcome::Ok(self.guardrails_view(&workspace, repo.as_ref()).await?))
199 }
200
201 /// Records a change to guardrails in the workspace's audit log. Never
202 /// fails the change: a log that cannot be written is logged.
203 async fn audit_guardrails(&self, actor: &User, workspace: &str, repo: Option<String>, message: &str) {
204 let entry = NewAuditEntry {
205 actor: AuditActor::of(actor),
206 action: "update_guardrails".to_owned(),
207 surface: Surface::Web,
208 target: AuditTarget { workspace: workspace.to_owned(), repo, ..AuditTarget::default() },
209 outcome: AuditOutcome::Allowed,
210 rule: "guardrails".to_owned(),
211 result: Some("ok".to_owned()),
212 message: Some(message.to_owned()),
213 request_id: new_id("req", now_ms()),
214 };
215 let recorded: Result<u32> = g1t_kit::call(&self.events, "audit_record", &RecordAuditArgs { entries: vec![entry] }).await;
216 if let Err(error) = recorded {
217 worker::console_error!("guardrails change not recorded: {error}");
218 }
219 }
220
221 /// What a run in `repo` gets. The runner is trusted: it names the
222 /// repository it is starting a sandbox in.
223 pub(crate) async fn run_guardrails(&self, a: RunGuardrailsArgs) -> Result<Outcome<Guardrails>> {
224 let path = self.project_path(&a).await?;
225 let workspace = path.namespace.to_lowercase();
226 let repo = match self.repo(&path, &member_of_service(&workspace)).await? {
227 Outcome::Ok(repo) => repo,
228 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
229 };
230 let level = self.guardrail_level("workspace", &workspace).await?;
231 let project = self.guardrail_level("project", &repo.id).await?;
232 Ok(Outcome::Ok(Guardrails::merge(&level, Some(&project))))
233 }
234
235 /// The project a run's guardrails come from, where it is now. By id
236 /// when the runner has it, so a repository renamed or transferred
237 /// since is still found; a pull request's working copy stands for the
238 /// repository the pull request is to (a preview built from it gets
239 /// that project's guardrails, not the `pulls` namespace's). Otherwise
240 /// the path as given.
241 async fn project_path(&self, a: &RunGuardrailsArgs) -> Result<RepoPath> {
242 let id = match (&a.repo_id, working_copy_of(&a.repo)) {
243 (Some(id), _) => Some(id.clone()),
244 (None, Some(pull_id)) => {
245 self.db
246 .prepare("SELECT repo_id AS value FROM pulls WHERE id = ?1 AND fork_name = ?1")
247 .bind(&[pull_id.into()])?
248 .first::<String>(Some("value"))
249 .await?
250 }
251 (None, None) => None,
252 };
253 let Some(id) = id else {
254 return Ok(a.repo.clone());
255 };
256 let current: Option<RepoPath> =
257 g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id }).await?;
258 Ok(current.unwrap_or_else(|| a.repo.clone()))
259 }
260
261 /// Ends a run that reached a cap of its guardrails, as stopped, and
262 /// leaves a pull request it was working on for a person, as a stop by
263 /// a person does. Called from `report_run` once its token is checked.
264 #[allow(clippy::too_many_arguments)]
265 pub(crate) async fn halt_run(
266 &self,
267 run_id: &str,
268 repo_id: &str,
269 pull_id: Option<&str>,
270 number: Option<u32>,
271 kind: &str,
272 halt: Halt,
273 detail: Option<String>,
274 cost_usd: Option<f64>,
275 ) -> Result<Outcome<RunStatus>> {
276 let said = detail
277 .map(|detail| crate::runs::one_line(&detail, 1000))
278 .filter(|detail| !detail.is_empty())
279 .unwrap_or_else(|| halt_message(halt).to_owned());
280 let now = rfc3339(now_ms());
281 let claimed = self
282 .db
283 .prepare(
284 "UPDATE agent_runs SET status = 'stopped', halted = ?1, step = ?2, error = ?2,
285 cost_usd = COALESCE(?3, cost_usd), started_at = COALESCE(started_at, ?4),
286 finished_at = ?4, updated_at = ?4
287 WHERE id = ?5 AND finished_at IS NULL RETURNING id AS value",
288 )
289 .bind(&[
290 halt.as_str().into(),
291 said.as_str().into(),
292 cost_usd
293 .filter(|cost| cost.is_finite() && *cost >= 0.0)
294 .map_or(JsValue::NULL, JsValue::from),
295 now.as_str().into(),
296 run_id.into(),
297 ])?
298 .first::<String>(Some("value"))
299 .await?;
300 if claimed.is_none() {
301 return Ok(Outcome::Ok(RunStatus::Stopped));
302 }
303 self.add_step(run_id, &now, &said)?.run().await?;
304 // Said on its pull request, if it was for one.
305 if let (Some(_), Some(number)) = (pull_id, number) {
306 let noted = match halt {
307 Halt::Abuse => format!("stopped its {kind} run for unusual CPU use"),
308 Halt::Budget => format!("stopped its {kind} run at its cost cap"),
309 Halt::Time => format!("stopped its {kind} run at its time cap"),
310 };
311 self.note(repo_id, number, (POLICY_ACTOR_ID, POLICY_ACTOR_NAME), &noted).await?;
312 }
313 Ok(Outcome::Ok(RunStatus::Stopped))
314 }
315}
316
317/// What a change to one level did, for the audit log: each time cap set,
318/// changed or cleared (inherited again).
319fn guardrails_change(before: &GuardrailSettings, after: &GuardrailSettings) -> String {
320 let mut parts = Vec::new();
321 for (kind, minutes) in &after.minutes {
322 match before.minutes.get(kind) {
323 Some(was) if was == minutes => {}
324 Some(was) => parts.push(format!("{kind} {was} to {minutes} minutes")),
325 None => parts.push(format!("{kind} set to {minutes} minutes")),
326 }
327 }
328 for kind in before.minutes.keys().filter(|kind| !after.minutes.contains_key(*kind)) {
329 parts.push(format!("{kind} inherited"));
330 }
331 if parts.is_empty() { "Guardrails saved unchanged.".to_owned() } else { format!("Time caps: {}.", parts.join("; ")) }
332}
333
334/// The pull request id of a pull request's working copy
335/// (`pulls/<pull id>`, made by repos `fork_for_pull`), if `path` is one.
336fn working_copy_of(path: &RepoPath) -> Option<String> {
337 (path.namespace.eq_ignore_ascii_case(WORKING_COPIES) && !path.name.is_empty())
338 .then(|| path.name.to_lowercase())
339}
340
341/// Where repos keeps every pull request's working copy.
342const WORKING_COPIES: &str = "pulls";
343
344/// A principal that can read any repository of `workspace`, for the
345/// runner's lookups.
346fn member_of_service(workspace: &str) -> Viewer {
347 member_of(
348 &User {
349 id: "svc_runner".to_owned(),
350 username: "g1t".to_owned(),
351 ..User::default()
352 },
353 workspace,
354 )
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 fn path(namespace: &str, name: &str) -> RepoPath {
362 RepoPath {
363 namespace: namespace.to_owned(),
364 name: name.to_owned(),
365 }
366 }
367
368 #[test]
369 fn a_working_copy_names_its_pull_request() {
370 assert_eq!(
371 working_copy_of(&path("pulls", "pr_01m45bd1b2e359sayh78w977kv")).as_deref(),
372 Some("pr_01m45bd1b2e359sayh78w977kv")
373 );
374 assert_eq!(working_copy_of(&path("Pulls", "PR_7")).as_deref(), Some("pr_7"));
375 assert_eq!(working_copy_of(&path("flagon-io", "automation-lab")), None);
376 assert_eq!(working_copy_of(&path("pulls", "")), None);
377 }
378
379 #[test]
380 fn run_guardrails_take_an_optional_repo_id() {
381 let old: RunGuardrailsArgs =
382 serde_json::from_str(r#"{"repo":{"namespace":"pulls","name":"pr_1"}}"#).unwrap();
383 assert!(old.repo_id.is_none());
384 let by_id: RunGuardrailsArgs = serde_json::from_str(
385 r#"{"repo":{"namespace":"syntaqx","name":"automation-lab"},"repo_id":"rep_1"}"#,
386 )
387 .unwrap();
388 assert_eq!(by_id.repo_id.as_deref(), Some("rep_1"));
389 }
390
391 #[test]
392 fn a_change_to_time_caps_is_described_for_the_audit_log() {
393 let caps = |pairs: &[(&str, u32)]| GuardrailSettings {
394 minutes: pairs.iter().map(|(kind, minutes)| ((*kind).to_owned(), *minutes)).collect(),
395 ..GuardrailSettings::default()
396 };
397 let before = caps(&[("implement", 90), ("review", 30)]);
398 let after = caps(&[("implement", 60), ("checks", 20)]);
399 assert_eq!(
400 guardrails_change(&before, &after),
401 "Time caps: checks set to 20 minutes; implement 90 to 60 minutes; review inherited."
402 );
403 assert_eq!(guardrails_change(&before, &before), "Guardrails saved unchanged.");
404 }
405
406 #[test]
407 fn renames_take_two_parameters() {
408 for sql in RENAMED {
409 assert!(sql.contains("?1") && sql.contains("?2"), "{sql}");
410 }
411 }
412
413 #[test]
414 fn a_level_reads_back_with_who_changed_it() {
415 let row = SettingsRow {
416 settings: r#"{"budgetUsd":2.5,"restrictNetwork":true,"domains":["example.com"],"minutes":{"review":15}}"#.to_owned(),
417 updated_by: "ada".to_owned(),
418 updated_at: "2026-10-04T00:00:00Z".to_owned(),
419 };
420 let settings = GuardrailSettings::from(row);
421 assert_eq!(settings.minutes.get("review"), Some(&15));
422 assert_eq!(settings.updated_by.as_deref(), Some("ada"));
423 }
424
425 #[test]
426 fn a_corrupt_level_reads_as_inheriting_everything() {
427 let row = SettingsRow {
428 settings: "not json".to_owned(),
429 updated_by: "ada".to_owned(),
430 updated_at: "2026-10-04T00:00:00Z".to_owned(),
431 };
432 let settings = GuardrailSettings::from(row);
433 assert!(settings.minutes.is_empty());
434 }
435}