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