flagon-io/g1t

public

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/services/work/src/guardrails.rs

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