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

293 lines11,330 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::agents::RunStatus;
10use g1t_contracts::guardrails::*;
11use g1t_contracts::repos::{Repo, RepoPath};
12use g1t_contracts::time::rfc3339;
13use g1t_contracts::work::StallArgs;
14use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, Viewer};
15use g1t_kit::now_ms;
16use serde::Deserialize;
17use worker::Result;
18use worker::wasm_bindgen::JsValue;
19
20use crate::Work;
21use crate::reviews::{AGENT_ID, AGENT_NAME};
22use crate::runs::member_of;
23
24/// Statements that move a renamed workspace's guardrails to its new slug.
25/// Run with the new slug as `?1` and the old as `?2`.
26pub(crate) const RENAMED: &[&str] = &[
27 "UPDATE guardrails SET scope_key = ?1 WHERE scope = 'workspace' AND scope_key = ?2",
28 "UPDATE guardrails SET workspace = ?1 WHERE workspace = ?2",
29];
30
31#[derive(Deserialize)]
32struct SettingsRow {
33 settings: String,
34 updated_by: String,
35 updated_at: String,
36}
37
38impl From<SettingsRow> for GuardrailSettings {
39 fn from(row: SettingsRow) -> Self {
40 let mut settings: GuardrailSettings = serde_json::from_str(&row.settings).unwrap_or_default();
41 settings.updated_by = Some(row.updated_by);
42 settings.updated_at = Some(row.updated_at);
43 settings
44 }
45}
46
47/// Whether `actor` is a verified person, not a token or an agent.
48fn is_person(actor: &User) -> bool {
49 actor.verified && actor.kind == PrincipalKind::User
50}
51
52/// What a halted run is told, and what its pull request says.
53fn halt_message(halt: Halt) -> &'static str {
54 match halt {
55 Halt::Budget => "Stopped: it reached its cost cap.",
56 Halt::Time => "Stopped: it reached its time cap.",
57 }
58}
59
60impl Work {
61 async fn guardrail_level(&self, scope: &str, key: &str) -> Result<GuardrailSettings> {
62 Ok(self
63 .db
64 .prepare("SELECT settings, updated_by, updated_at FROM guardrails WHERE scope = ? AND scope_key = ?")
65 .bind(&[scope.into(), key.into()])?
66 .first::<SettingsRow>(None)
67 .await?
68 .map(GuardrailSettings::from)
69 .unwrap_or_default())
70 }
71
72 /// The project at `path`, which must be in `workspace`.
73 async fn guarded_repo(&self, path: &RepoPath, viewer: &Viewer, workspace: &str) -> Result<Outcome<Repo>> {
74 Ok(match self.repo(path, viewer).await? {
75 Outcome::Ok(repo) if repo.namespace.to_lowercase() == workspace => Outcome::Ok(repo),
76 Outcome::Ok(_) => Outcome::fail(FailureCode::Invalid, "That project is in another workspace."),
77 Outcome::Fail(failure) => Outcome::Fail(failure),
78 })
79 }
80
81 async fn guardrails_view(&self, workspace: &str, repo: Option<&Repo>) -> Result<GuardrailsView> {
82 let level = self.guardrail_level("workspace", workspace).await?;
83 let project = match repo {
84 Some(repo) => Some(self.guardrail_level("project", &repo.id).await?),
85 None => None,
86 };
87 Ok(GuardrailsView::new(level, project))
88 }
89
90 pub(crate) async fn get_guardrails(&self, a: GetGuardrailsArgs) -> Result<Outcome<GuardrailsView>> {
91 let workspace = a.workspace.to_lowercase();
92 if !a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&workspace)) {
93 return Ok(Outcome::fail(
94 FailureCode::Forbidden,
95 "Guardrails are for members of the workspace.",
96 ));
97 }
98 let repo = match &a.repo {
99 Some(path) => match self.guarded_repo(path, &a.viewer, &workspace).await? {
100 Outcome::Ok(repo) => Some(repo),
101 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
102 },
103 None => None,
104 };
105 Ok(Outcome::Ok(self.guardrails_view(&workspace, repo.as_ref()).await?))
106 }
107
108 pub(crate) async fn update_guardrails(&self, a: UpdateGuardrailsArgs) -> Result<Outcome<GuardrailsView>> {
109 let workspace = a.workspace.to_lowercase();
110 let viewer = Some(a.actor.clone());
111 let repo = match &a.repo {
112 Some(path) => match self.guarded_repo(path, &viewer, &workspace).await? {
113 Outcome::Ok(repo) => Some(repo),
114 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
115 },
116 None => None,
117 };
118 // A project's guardrails are its members' to set, as its other
119 // settings are; the defaults every project inherits are the owners'.
120 let allowed = is_person(&a.actor)
121 && match repo {
122 Some(_) => a.actor.is_member(&workspace),
123 None => a.actor.role_in(&workspace) == Some(Role::Owner),
124 };
125 if !allowed {
126 return Ok(Outcome::fail(
127 FailureCode::Forbidden,
128 if repo.is_some() {
129 "Only members of the workspace can change a project's guardrails."
130 } else {
131 "Only owners of the workspace can change its guardrails."
132 },
133 ));
134 }
135 let mut settings = match validate(a.settings) {
136 Ok(settings) => settings,
137 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
138 };
139 settings.updated_by = None;
140 settings.updated_at = None;
141 let (scope, key) = match &repo {
142 Some(repo) => ("project", repo.id.clone()),
143 None => ("workspace", workspace.clone()),
144 };
145 self.db
146 .prepare(
147 "INSERT INTO guardrails (scope, scope_key, workspace, settings, updated_by, updated_at)
148 VALUES (?, ?, ?, ?, ?, ?)
149 ON CONFLICT (scope, scope_key) DO UPDATE SET
150 settings = excluded.settings,
151 updated_by = excluded.updated_by,
152 updated_at = excluded.updated_at",
153 )
154 .bind(&[
155 scope.into(),
156 key.into(),
157 workspace.as_str().into(),
158 serde_json::to_string(&settings)?.into(),
159 a.actor.username.as_str().into(),
160 rfc3339(now_ms()).into(),
161 ])?
162 .run()
163 .await?;
164 Ok(Outcome::Ok(self.guardrails_view(&workspace, repo.as_ref()).await?))
165 }
166
167 /// What a run in `repo` gets. The runner is trusted: it names the
168 /// repository it is starting a sandbox in.
169 pub(crate) async fn run_guardrails(&self, a: RunGuardrailsArgs) -> Result<Outcome<Guardrails>> {
170 let workspace = a.repo.namespace.to_lowercase();
171 let repo = match self.repo(&a.repo, &member_of_service(&workspace)).await? {
172 Outcome::Ok(repo) => repo,
173 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
174 };
175 let level = self.guardrail_level("workspace", &workspace).await?;
176 let project = self.guardrail_level("project", &repo.id).await?;
177 Ok(Outcome::Ok(Guardrails::merge(&level, Some(&project))))
178 }
179
180 /// Ends a run that reached a cap of its guardrails, as stopped, and
181 /// leaves a pull request it was working on for a person, as a stop by
182 /// a person does. Called from `report_run` once its token is checked.
183 #[allow(clippy::too_many_arguments)]
184 pub(crate) async fn halt_run(
185 &self,
186 run_id: &str,
187 repo_id: &str,
188 pull_id: Option<&str>,
189 number: Option<u32>,
190 kind: &str,
191 halt: Halt,
192 detail: Option<String>,
193 cost_usd: Option<f64>,
194 ) -> Result<Outcome<RunStatus>> {
195 let said = detail
196 .map(|detail| crate::runs::one_line(&detail, 1000))
197 .filter(|detail| !detail.is_empty())
198 .unwrap_or_else(|| halt_message(halt).to_owned());
199 let now = rfc3339(now_ms());
200 let claimed = self
201 .db
202 .prepare(
203 "UPDATE agent_runs SET status = 'stopped', halted = ?1, step = ?2, error = ?2,
204 cost_usd = COALESCE(?3, cost_usd), started_at = COALESCE(started_at, ?4),
205 finished_at = ?4, updated_at = ?4
206 WHERE id = ?5 AND finished_at IS NULL RETURNING id AS value",
207 )
208 .bind(&[
209 halt.as_str().into(),
210 said.as_str().into(),
211 cost_usd
212 .filter(|cost| cost.is_finite() && *cost >= 0.0)
213 .map_or(JsValue::NULL, JsValue::from),
214 now.as_str().into(),
215 run_id.into(),
216 ])?
217 .first::<String>(Some("value"))
218 .await?;
219 if claimed.is_none() {
220 return Ok(Outcome::Ok(RunStatus::Stopped));
221 }
222 self.add_step(run_id, &now, &said)?.run().await?;
223 if let (Some(pull_id), Some(number)) = (pull_id, number) {
224 let cap = match halt {
225 Halt::Budget => "cost cap",
226 Halt::Time => "time cap",
227 };
228 self.stall(StallArgs {
229 pull_id: pull_id.to_owned(),
230 reason: format!(
231 "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."
232 ),
233 })
234 .await?;
235 self.note(
236 repo_id,
237 number,
238 (AGENT_ID, AGENT_NAME),
239 &format!("stopped its {kind} run at its {cap}"),
240 )
241 .await?;
242 }
243 Ok(Outcome::Ok(RunStatus::Stopped))
244 }
245}
246
247/// A principal that can read any repository of `workspace`, for the
248/// runner's lookups.
249fn member_of_service(workspace: &str) -> Viewer {
250 member_of(
251 &User {
252 id: "svc_runner".to_owned(),
253 username: "g1t".to_owned(),
254 ..User::default()
255 },
256 workspace,
257 )
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn renames_take_two_parameters() {
266 for sql in RENAMED {
267 assert!(sql.contains("?1") && sql.contains("?2"), "{sql}");
268 }
269 }
270
271 #[test]
272 fn a_level_reads_back_with_who_changed_it() {
273 let row = SettingsRow {
274 settings: r#"{"budgetUsd":2.5,"domains":["example.com"]}"#.to_owned(),
275 updated_by: "ada".to_owned(),
276 updated_at: "2026-10-04T00:00:00Z".to_owned(),
277 };
278 let settings = GuardrailSettings::from(row);
279 assert_eq!(settings.budget_usd, Some(2.5));
280 assert_eq!(settings.domains, vec!["example.com"]);
281 assert_eq!(settings.updated_by.as_deref(), Some("ada"));
282 }
283
284 #[test]
285 fn a_corrupt_level_reads_as_inheriting_everything() {
286 let row = SettingsRow {
287 settings: "not json".to_owned(),
288 updated_by: "ada".to_owned(),
289 updated_at: "2026-10-04T00:00:00Z".to_owned(),
290 };
291 assert_eq!(GuardrailSettings::from(row).budget_usd, None);
292 }
293}