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