pr_01m47d24b0e6n91zwymwxg0vpx/services/work/src/guardrails.rs

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