g1t/services/work/src/guardrails.rs

386 lines15,703 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1//! 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look9use g1t_contracts::access::{self, Capability};
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API10use 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look32/// 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
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API39#[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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look60/// 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
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API63/// 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.",
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look68 Halt::Abuse => ABUSE_MESSAGE,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API69 }
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();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look104 let member = a.viewer.as_ref().is_some_and(|viewer| viewer.is_member(&workspace));
105 if !member && a.repo.is_none() {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API106 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 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look118 // 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 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API130 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 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look143 // A project's guardrails go with its branch protection (Maintain);
144 // the defaults every project inherits are the owners'.
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API145 let allowed = is_person(&a.actor)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look146 && match &repo {
147 Some(repo) => access::can(Some(&a.actor), repo, Capability::ManageProtection),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API148 None => a.actor.role_in(&workspace) == Some(Role::Owner),
149 };
150 if !allowed {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look151 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));
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API159 }
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>> {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas195 let path = self.project_path(&a).await?;
196 let workspace = path.namespace.to_lowercase();
197 let repo = match self.repo(&path, &member_of_service(&workspace)).await? {
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API198 Outcome::Ok(repo) => repo,
199 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
200 };
201 let level = self.guardrail_level("workspace", &workspace).await?;
202 let project = self.guardrail_level("project", &repo.id).await?;
203 Ok(Outcome::Ok(Guardrails::merge(&level, Some(&project))))
204 }
205
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas206 /// The project a run's guardrails come from, where it is now. By id
207 /// when the runner has it, so a repository renamed or transferred
208 /// since is still found; a pull request's working copy stands for the
209 /// repository the pull request is to (a preview built from it gets
210 /// that project's guardrails, not the `pulls` namespace's). Otherwise
211 /// the path as given.
212 async fn project_path(&self, a: &RunGuardrailsArgs) -> Result<RepoPath> {
213 let id = match (&a.repo_id, working_copy_of(&a.repo)) {
214 (Some(id), _) => Some(id.clone()),
215 (None, Some(pull_id)) => {
216 self.db
217 .prepare("SELECT repo_id AS value FROM pulls WHERE id = ?1 AND fork_name = ?1")
218 .bind(&[pull_id.into()])?
219 .first::<String>(Some("value"))
220 .await?
221 }
222 (None, None) => None,
223 };
224 let Some(id) = id else {
225 return Ok(a.repo.clone());
226 };
227 let current: Option<RepoPath> =
228 g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id }).await?;
229 Ok(current.unwrap_or_else(|| a.repo.clone()))
230 }
231
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API232 /// Ends a run that reached a cap of its guardrails, as stopped, and
233 /// leaves a pull request it was working on for a person, as a stop by
234 /// a person does. Called from `report_run` once its token is checked.
235 #[allow(clippy::too_many_arguments)]
236 pub(crate) async fn halt_run(
237 &self,
238 run_id: &str,
239 repo_id: &str,
240 pull_id: Option<&str>,
241 number: Option<u32>,
242 kind: &str,
243 halt: Halt,
244 detail: Option<String>,
245 cost_usd: Option<f64>,
246 ) -> Result<Outcome<RunStatus>> {
247 let said = detail
248 .map(|detail| crate::runs::one_line(&detail, 1000))
249 .filter(|detail| !detail.is_empty())
250 .unwrap_or_else(|| halt_message(halt).to_owned());
251 let now = rfc3339(now_ms());
252 let claimed = self
253 .db
254 .prepare(
255 "UPDATE agent_runs SET status = 'stopped', halted = ?1, step = ?2, error = ?2,
256 cost_usd = COALESCE(?3, cost_usd), started_at = COALESCE(started_at, ?4),
257 finished_at = ?4, updated_at = ?4
258 WHERE id = ?5 AND finished_at IS NULL RETURNING id AS value",
259 )
260 .bind(&[
261 halt.as_str().into(),
262 said.as_str().into(),
263 cost_usd
264 .filter(|cost| cost.is_finite() && *cost >= 0.0)
265 .map_or(JsValue::NULL, JsValue::from),
266 now.as_str().into(),
267 run_id.into(),
268 ])?
269 .first::<String>(Some("value"))
270 .await?;
271 if claimed.is_none() {
272 return Ok(Outcome::Ok(RunStatus::Stopped));
273 }
274 self.add_step(run_id, &now, &said)?.run().await?;
275 if let (Some(pull_id), Some(number)) = (pull_id, number) {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look276 let (reason, noted) = match halt {
277 Halt::Abuse => (
278 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."),
279 format!("stopped its {kind} run for unusual CPU use"),
280 ),
281 Halt::Budget | Halt::Time => {
282 let cap = if halt == Halt::Budget { "cost cap" } else { "time cap" };
283 (
284 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."),
285 format!("stopped its {kind} run at its {cap}"),
286 )
287 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API288 };
289 self.stall(StallArgs {
290 pull_id: pull_id.to_owned(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look291 reason,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API292 })
293 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look294 self.note(repo_id, number, (AGENT_ID, AGENT_NAME), &noted).await?;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API295 }
296 Ok(Outcome::Ok(RunStatus::Stopped))
297 }
298}
299
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas300/// The pull request id of a pull request's working copy
301/// (`pulls/<pull id>`, made by repos `fork_for_pull`), if `path` is one.
302fn working_copy_of(path: &RepoPath) -> Option<String> {
303 (path.namespace.eq_ignore_ascii_case(WORKING_COPIES) && !path.name.is_empty())
304 .then(|| path.name.to_lowercase())
305}
306
307/// Where repos keeps every pull request's working copy.
308const WORKING_COPIES: &str = "pulls";
309
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API310/// A principal that can read any repository of `workspace`, for the
311/// runner's lookups.
312fn member_of_service(workspace: &str) -> Viewer {
313 member_of(
314 &User {
315 id: "svc_runner".to_owned(),
316 username: "g1t".to_owned(),
317 ..User::default()
318 },
319 workspace,
320 )
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas327 fn path(namespace: &str, name: &str) -> RepoPath {
328 RepoPath {
329 namespace: namespace.to_owned(),
330 name: name.to_owned(),
331 }
332 }
333
334 #[test]
335 fn a_working_copy_names_its_pull_request() {
336 assert_eq!(
337 working_copy_of(&path("pulls", "pr_01m45bd1b2e359sayh78w977kv")).as_deref(),
338 Some("pr_01m45bd1b2e359sayh78w977kv")
339 );
340 assert_eq!(working_copy_of(&path("Pulls", "PR_7")).as_deref(), Some("pr_7"));
341 assert_eq!(working_copy_of(&path("flagon-io", "automation-lab")), None);
342 assert_eq!(working_copy_of(&path("pulls", "")), None);
343 }
344
345 #[test]
346 fn run_guardrails_take_an_optional_repo_id() {
347 let old: RunGuardrailsArgs =
348 serde_json::from_str(r#"{"repo":{"namespace":"pulls","name":"pr_1"}}"#).unwrap();
349 assert!(old.repo_id.is_none());
350 let by_id: RunGuardrailsArgs = serde_json::from_str(
351 r#"{"repo":{"namespace":"syntaqx","name":"automation-lab"},"repo_id":"rep_1"}"#,
352 )
353 .unwrap();
354 assert_eq!(by_id.repo_id.as_deref(), Some("rep_1"));
355 }
356
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API357 #[test]
358 fn renames_take_two_parameters() {
359 for sql in RENAMED {
360 assert!(sql.contains("?1") && sql.contains("?2"), "{sql}");
361 }
362 }
363
364 #[test]
365 fn a_level_reads_back_with_who_changed_it() {
366 let row = SettingsRow {
367 settings: r#"{"budgetUsd":2.5,"domains":["example.com"]}"#.to_owned(),
368 updated_by: "ada".to_owned(),
369 updated_at: "2026-10-04T00:00:00Z".to_owned(),
370 };
371 let settings = GuardrailSettings::from(row);
372 assert_eq!(settings.budget_usd, Some(2.5));
373 assert_eq!(settings.domains, vec!["example.com"]);
374 assert_eq!(settings.updated_by.as_deref(), Some("ada"));
375 }
376
377 #[test]
378 fn a_corrupt_level_reads_as_inheriting_everything() {
379 let row = SettingsRow {
380 settings: "not json".to_owned(),
381 updated_by: "ada".to_owned(),
382 updated_at: "2026-10-04T00:00:00Z".to_owned(),
383 };
384 assert_eq!(GuardrailSettings::from(row).budget_usd, None);
385 }
386}