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
443 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//! levels saved with them still read, and they are dropped when saved again.
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 let (scope, key) = match &repo {
171 Some(repo) => ("project", repo.id.clone()),
172 None => ("workspace", workspace.clone()),
173 };
174 let before = self.guardrail_level(scope, &key).await?;
175 self.db
176 .prepare(
177 "INSERT INTO guardrails (scope, scope_key, workspace, settings, updated_by, updated_at)
178 VALUES (?, ?, ?, ?, ?, ?)
179 ON CONFLICT (scope, scope_key) DO UPDATE SET
180 settings = excluded.settings,
181 updated_by = excluded.updated_by,
182 updated_at = excluded.updated_at",
183 )
184 .bind(&[
185 scope.into(),
186 key.into(),
187 workspace.as_str().into(),
188 serde_json::to_string(&settings)?.into(),
189 a.actor.username.as_str().into(),
190 rfc3339(now_ms()).into(),
191 ])?
192 .run()
193 .await?;
194 let full = repo.as_ref().map(|repo| format!("{}/{}", repo.namespace, repo.name));
195 self.audit_guardrails(&a.actor, &workspace, full, &guardrails_change(&before, &settings)).await;
196 Ok(Outcome::Ok(self.guardrails_view(&workspace, repo.as_ref()).await?))
197 }
198
199 /// Records a change to guardrails in the workspace's audit log. Never
200 /// fails the change: a log that cannot be written is logged.
201 async fn audit_guardrails(&self, actor: &User, workspace: &str, repo: Option<String>, message: &str) {
202 let entry = NewAuditEntry {
203 actor: AuditActor::of(actor),
204 action: "update_guardrails".to_owned(),
205 surface: Surface::Web,
206 target: AuditTarget { workspace: workspace.to_owned(), repo, ..AuditTarget::default() },
207 outcome: AuditOutcome::Allowed,
208 rule: "guardrails".to_owned(),
209 result: Some("ok".to_owned()),
210 message: Some(message.to_owned()),
211 request_id: new_id("req", now_ms()),
212 };
213 let recorded: Result<u32> = g1t_kit::call(&self.events, "audit_record", &RecordAuditArgs { entries: vec![entry] }).await;
214 if let Err(error) = recorded {
215 worker::console_error!("guardrails change not recorded: {error}");
216 }
217 }
218
219 /// What a run in `repo` gets. The runner is trusted: it names the
220 /// repository it is starting a sandbox in.
221 pub(crate) async fn run_guardrails(&self, a: RunGuardrailsArgs) -> Result<Outcome<Guardrails>> {
222 let path = self.project_path(&a).await?;
223 let workspace = path.namespace.to_lowercase();
224 let repo = match self.repo(&path, &member_of_service(&workspace)).await? {
225 Outcome::Ok(repo) => repo,
226 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
227 };
228 let level = self.guardrail_level("workspace", &workspace).await?;
229 let project = self.guardrail_level("project", &repo.id).await?;
230 Ok(Outcome::Ok(Guardrails::merge(&level, Some(&project))))
231 }
232
233 /// The project a run's guardrails come from, where it is now. By id
234 /// when the runner has it, so a repository renamed or transferred
235 /// since is still found; a pull request's working copy stands for the
236 /// repository the pull request is to (a preview built from it gets
237 /// that project's guardrails, not the `pulls` namespace's). Otherwise
238 /// the path as given.
239 async fn project_path(&self, a: &RunGuardrailsArgs) -> Result<RepoPath> {
240 let id = match (&a.repo_id, working_copy_of(&a.repo)) {
241 (Some(id), _) => Some(id.clone()),
242 (None, Some(pull_id)) => {
243 self.db
244 .prepare("SELECT repo_id AS value FROM pulls WHERE id = ?1 AND fork_name = ?1")
245 .bind(&[pull_id.into()])?
246 .first::<String>(Some("value"))
247 .await?
248 }
249 (None, None) => None,
250 };
251 let Some(id) = id else {
252 return Ok(a.repo.clone());
253 };
254 let current: Option<RepoPath> =
255 g1t_kit::call(&self.repos, "path_by_id", &g1t_contracts::repos::PathByIdArgs { id }).await?;
256 Ok(current.unwrap_or_else(|| a.repo.clone()))
257 }
258
259 /// Ends a run that reached a cap of its guardrails, as stopped, and
260 /// leaves a pull request it was working on for a person, as a stop by
261 /// a person does. Called from `report_run` once its token is checked.
262 #[allow(clippy::too_many_arguments)]
263 pub(crate) async fn halt_run(
264 &self,
265 run_id: &str,
266 repo_id: &str,
267 pull_id: Option<&str>,
268 number: Option<u32>,
269 kind: &str,
270 halt: Halt,
271 detail: Option<String>,
272 cost_usd: Option<f64>,
273 ) -> Result<Outcome<RunStatus>> {
274 let said = detail
275 .map(|detail| crate::runs::one_line(&detail, 1000))
276 .filter(|detail| !detail.is_empty())
277 .unwrap_or_else(|| halt_message(halt).to_owned());
278 let now = rfc3339(now_ms());
279 let claimed = self
280 .db
281 .prepare(
282 "UPDATE agent_runs SET status = 'stopped', halted = ?1, step = ?2, error = ?2,
283 cost_usd = COALESCE(?3, cost_usd), started_at = COALESCE(started_at, ?4),
284 finished_at = ?4, updated_at = ?4
285 WHERE id = ?5 AND finished_at IS NULL RETURNING id AS value",
286 )
287 .bind(&[
288 halt.as_str().into(),
289 said.as_str().into(),
290 cost_usd
291 .filter(|cost| cost.is_finite() && *cost >= 0.0)
292 .map_or(JsValue::NULL, JsValue::from),
293 now.as_str().into(),
294 run_id.into(),
295 ])?
296 .first::<String>(Some("value"))
297 .await?;
298 if claimed.is_none() {
299 return Ok(Outcome::Ok(RunStatus::Stopped));
300 }
301 self.add_step(run_id, &now, &said)?.run().await?;
302 // Said on its pull request, if it was for one.
303 if let (Some(_), Some(number)) = (pull_id, number) {
304 let noted = match halt {
305 Halt::Abuse => format!("stopped its {kind} run for unusual CPU use"),
306 Halt::Budget => format!("stopped its {kind} run at its cost cap"),
307 Halt::Time => format!("stopped its {kind} run at its time cap"),
308 };
309 self.note(repo_id, number, (POLICY_ACTOR_ID, POLICY_ACTOR_NAME), &noted).await?;
310 }
311 Ok(Outcome::Ok(RunStatus::Stopped))
312 }
313}
314
315/// What a change to one level did, for the audit log: the workflow-only
316/// domains added and removed, each with what it is limited to, and
317/// whether anything else changed.
318fn guardrails_change(before: &GuardrailSettings, after: &GuardrailSettings) -> String {
319 let describe = |entry: &WorkflowDomain| {
320 let workflows = if entry.workflows.is_empty() { "any workflow".to_owned() } else { entry.workflows.join(", ") };
321 let environments = if entry.environments.is_empty() { "any environment".to_owned() } else { entry.environments.join(", ") };
322 format!("{} ({workflows}; {environments})", entry.domain)
323 };
324 let added: Vec<String> = after.workflow_domains.iter().filter(|e| !before.workflow_domains.contains(e)).map(describe).collect();
325 let removed: Vec<String> = before.workflow_domains.iter().filter(|e| !after.workflow_domains.contains(e)).map(describe).collect();
326 let mut parts = Vec::new();
327 if !added.is_empty() {
328 parts.push(format!("Workflow-only domains added: {}.", added.join("; ")));
329 }
330 if !removed.is_empty() {
331 parts.push(format!("Workflow-only domains removed: {}.", removed.join("; ")));
332 }
333 let rest = |s: &GuardrailSettings| GuardrailSettings { workflow_domains: Vec::new(), updated_by: None, updated_at: None, ..s.clone() };
334 if rest(before) != rest(after) {
335 parts.push("Other guardrails changed.".to_owned());
336 }
337 if parts.is_empty() { "Guardrails saved unchanged.".to_owned() } else { parts.join(" ") }
338}
339
340/// The pull request id of a pull request's working copy
341/// (`pulls/<pull id>`, made by repos `fork_for_pull`), if `path` is one.
342fn working_copy_of(path: &RepoPath) -> Option<String> {
343 (path.namespace.eq_ignore_ascii_case(WORKING_COPIES) && !path.name.is_empty())
344 .then(|| path.name.to_lowercase())
345}
346
347/// Where repos keeps every pull request's working copy.
348const WORKING_COPIES: &str = "pulls";
349
350/// A principal that can read any repository of `workspace`, for the
351/// runner's lookups.
352fn member_of_service(workspace: &str) -> Viewer {
353 member_of(
354 &User {
355 id: "svc_runner".to_owned(),
356 username: "g1t".to_owned(),
357 ..User::default()
358 },
359 workspace,
360 )
361}
362
363#[cfg(test)]
364mod tests {
365 use super::*;
366
367 fn path(namespace: &str, name: &str) -> RepoPath {
368 RepoPath {
369 namespace: namespace.to_owned(),
370 name: name.to_owned(),
371 }
372 }
373
374 #[test]
375 fn a_working_copy_names_its_pull_request() {
376 assert_eq!(
377 working_copy_of(&path("pulls", "pr_01m45bd1b2e359sayh78w977kv")).as_deref(),
378 Some("pr_01m45bd1b2e359sayh78w977kv")
379 );
380 assert_eq!(working_copy_of(&path("Pulls", "PR_7")).as_deref(), Some("pr_7"));
381 assert_eq!(working_copy_of(&path("flagon-io", "automation-lab")), None);
382 assert_eq!(working_copy_of(&path("pulls", "")), None);
383 }
384
385 #[test]
386 fn run_guardrails_take_an_optional_repo_id() {
387 let old: RunGuardrailsArgs =
388 serde_json::from_str(r#"{"repo":{"namespace":"pulls","name":"pr_1"}}"#).unwrap();
389 assert!(old.repo_id.is_none());
390 let by_id: RunGuardrailsArgs = serde_json::from_str(
391 r#"{"repo":{"namespace":"syntaqx","name":"automation-lab"},"repo_id":"rep_1"}"#,
392 )
393 .unwrap();
394 assert_eq!(by_id.repo_id.as_deref(), Some("rep_1"));
395 }
396
397 #[test]
398 fn a_change_to_workflow_domains_is_described_for_the_audit_log() {
399 let deploy = WorkflowDomain {
400 domain: "api.cloudflare.com".into(),
401 workflows: vec!["deploy.yml".into()],
402 environments: vec!["production".into()],
403 };
404 let open = WorkflowDomain { domain: "*.example.com".into(), ..WorkflowDomain::default() };
405 let before = GuardrailSettings { workflow_domains: vec![open.clone()], ..GuardrailSettings::default() };
406 let after = GuardrailSettings { workflow_domains: vec![deploy], restrict_network: Some(false), ..GuardrailSettings::default() };
407 assert_eq!(
408 guardrails_change(&before, &after),
409 "Workflow-only domains added: api.cloudflare.com (deploy.yml; production). Workflow-only domains removed: *.example.com (any workflow; any environment). Other guardrails changed."
410 );
411 assert_eq!(guardrails_change(&before, &before), "Guardrails saved unchanged.");
412 }
413
414 #[test]
415 fn renames_take_two_parameters() {
416 for sql in RENAMED {
417 assert!(sql.contains("?1") && sql.contains("?2"), "{sql}");
418 }
419 }
420
421 #[test]
422 fn a_level_reads_back_with_who_changed_it() {
423 let row = SettingsRow {
424 settings: r#"{"budgetUsd":2.5,"domains":["example.com"]}"#.to_owned(),
425 updated_by: "ada".to_owned(),
426 updated_at: "2026-10-04T00:00:00Z".to_owned(),
427 };
428 let settings = GuardrailSettings::from(row);
429 assert_eq!(settings.domains, vec!["example.com"]);
430 assert_eq!(settings.updated_by.as_deref(), Some("ada"));
431 }
432
433 #[test]
434 fn a_corrupt_level_reads_as_inheriting_everything() {
435 let row = SettingsRow {
436 settings: "not json".to_owned(),
437 updated_by: "ada".to_owned(),
438 updated_at: "2026-10-04T00:00:00Z".to_owned(),
439 };
440 let settings = GuardrailSettings::from(row);
441 assert!(settings.domains.is_empty() && settings.restrict_network.is_none());
442 }
443}