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
344 linesCodeBlameRaw
1//! Guardrails: how long a workspace's runs may take. Kept by the work
2//! service.
3//!
4//! A workspace sets defaults and each project may override them: a time
5//! cap for each kind of run, after which g1t stops it. Sandboxes reach the
6//! internet as any CI machine does; guardrails say nothing about the
7//! network.
8//!
9//! Levels saved when guardrails also had network rules (restricting the
10//! network, package registries, allowed domains and workflow-only
11//! domains), command rules, deny patterns and a cost cap still read: those
12//! fields are ignored, and dropped when the level is saved again.
13//!
14//! Each `*Args` struct is the argument of the method of the same name,
15//! served at `POST /rpc/<method>`.
16
17use std::collections::BTreeMap;
18
19use serde::{Deserialize, Serialize};
20
21use crate::agents::RunKind;
22use crate::repos::RepoPath;
23use crate::{User, Viewer};
24
25/// The longest any run may be allowed to take, in minutes.
26pub const MAX_MINUTES: u32 = 240;
27
28/// How long each kind of run may take unless the workspace says otherwise.
29pub fn default_minutes(kind: RunKind) -> u32 {
30 match kind {
31 RunKind::Implement => 90,
32 RunKind::Revise => 60,
33 RunKind::Review => 30,
34 RunKind::Answer => 20,
35 RunKind::Update => 45,
36 RunKind::Plan => 30,
37 RunKind::Checks => 45,
38 RunKind::Queue => 45,
39 RunKind::Mergecheck => 10,
40 }
41}
42
43/// What one level, the workspace or a project, sets. Anything left unset
44/// is inherited: a project from its workspace, a workspace from g1t's
45/// defaults.
46#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase", default)]
48pub struct GuardrailSettings {
49 /// How long a run may take, in minutes, by kind of run.
50 pub minutes: BTreeMap<String, u32>,
51 /// Username of whoever last changed this level.
52 pub updated_by: Option<String>,
53 /// RFC 3339.
54 pub updated_at: Option<String>,
55}
56
57/// The guardrails a run actually gets: g1t's defaults, then the
58/// workspace's, then the project's.
59#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61pub struct Guardrails {
62 /// Every kind of run.
63 pub minutes: BTreeMap<String, u32>,
64}
65
66impl Guardrails {
67 /// g1t's defaults: a time cap for each kind of run.
68 pub fn defaults() -> Self {
69 Guardrails {
70 minutes: RunKind::ALL
71 .into_iter()
72 .map(|kind| (kind.as_str().to_owned(), default_minutes(kind)))
73 .collect(),
74 }
75 }
76
77 /// One level laid over what it inherits.
78 pub fn apply(mut self, level: &GuardrailSettings) -> Self {
79 for (kind, minutes) in &level.minutes {
80 if let Some(cap) = self.minutes.get_mut(kind) {
81 *cap = *minutes;
82 }
83 }
84 self
85 }
86
87 /// The workspace's defaults with a project's overrides on top.
88 pub fn merge(workspace: &GuardrailSettings, project: Option<&GuardrailSettings>) -> Self {
89 let inherited = Guardrails::defaults().apply(workspace);
90 match project {
91 Some(project) => inherited.apply(project),
92 None => inherited,
93 }
94 }
95
96 /// The time cap of a kind of run, in minutes.
97 pub fn minutes_for(&self, kind: RunKind) -> u32 {
98 self.minutes
99 .get(kind.as_str())
100 .copied()
101 .unwrap_or_else(|| default_minutes(kind))
102 }
103}
104
105/// A host name as it is kept: lower case, no scheme, path or port,
106/// optionally `*.` for its subdomains. Refused if it is not a host name.
107/// The hosts a brokered secret is for are kept this way.
108pub fn normalize_domain(input: &str) -> Result<String, String> {
109 let mut domain = input.trim().to_lowercase();
110 for scheme in ["https://", "http://"] {
111 if let Some(rest) = domain.strip_prefix(scheme) {
112 domain = rest.to_owned();
113 }
114 }
115 if let Some(at) = domain.find(['/', ':']) {
116 domain.truncate(at);
117 }
118 let domain = domain.trim_end_matches('.').to_owned();
119 let bare = domain.strip_prefix("*.").unwrap_or(&domain);
120 let labels: Vec<&str> = bare.split('.').collect();
121 let valid = labels.len() >= 2
122 && bare.len() <= 253
123 && labels.iter().all(|label| {
124 !label.is_empty()
125 && label.len() <= 63
126 && !label.starts_with('-')
127 && !label.ends_with('-')
128 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
129 });
130 if valid {
131 Ok(domain)
132 } else {
133 Err(format!("{} is not a domain. Use a host name such as example.com, or *.example.com for its subdomains.", input.trim()))
134 }
135}
136
137/// One level's settings, checked and tidied before they are kept.
138pub fn validate(settings: GuardrailSettings) -> Result<GuardrailSettings, String> {
139 let mut minutes = BTreeMap::new();
140 for (kind, cap) in settings.minutes {
141 if RunKind::parse(&kind).is_none() {
142 return Err(format!("{kind} is not a kind of run."));
143 }
144 if !(1..=MAX_MINUTES).contains(&cap) {
145 return Err(format!("A run's time cap is between 1 and {MAX_MINUTES} minutes."));
146 }
147 minutes.insert(kind, cap);
148 }
149 Ok(GuardrailSettings {
150 minutes,
151 updated_by: settings.updated_by,
152 updated_at: settings.updated_at,
153 })
154}
155
156/// Everything the settings pages show: each level as it was set, what
157/// each inherits, and what is in force.
158#[derive(Clone, Debug, Serialize, Deserialize)]
159#[serde(rename_all = "camelCase")]
160pub struct GuardrailsView {
161 pub workspace: GuardrailSettings,
162 /// None when no project was asked about.
163 pub project: Option<GuardrailSettings>,
164 pub defaults: Guardrails,
165 /// g1t's defaults with the workspace's: what a project inherits.
166 pub inherited: Guardrails,
167 /// What runs get: the project's, or with no project, the workspace's.
168 pub effective: Guardrails,
169}
170
171impl GuardrailsView {
172 pub fn new(workspace: GuardrailSettings, project: Option<GuardrailSettings>) -> Self {
173 let inherited = Guardrails::merge(&workspace, None);
174 let effective = Guardrails::merge(&workspace, project.as_ref());
175 GuardrailsView {
176 workspace,
177 project,
178 defaults: Guardrails::defaults(),
179 inherited,
180 effective,
181 }
182 }
183}
184
185/// `get_guardrails`: a workspace's guardrails, and with `repo`, that
186/// project's too. Members only. Returns `Outcome<GuardrailsView>`.
187#[derive(Debug, Serialize, Deserialize)]
188pub struct GetGuardrailsArgs {
189 pub viewer: Viewer,
190 pub workspace: String,
191 #[serde(default)]
192 pub repo: Option<RepoPath>,
193}
194
195/// `update_guardrails`: replaces one level's settings: the workspace's
196/// (owners only) or, with `repo`, that project's (members). Returns
197/// `Outcome<GuardrailsView>`.
198#[derive(Debug, Serialize, Deserialize)]
199pub struct UpdateGuardrailsArgs {
200 pub actor: User,
201 pub workspace: String,
202 #[serde(default)]
203 pub repo: Option<RepoPath>,
204 pub settings: GuardrailSettings,
205}
206
207/// `run_guardrails`: what a run in `repo` gets. For the runner service,
208/// which is trusted. Returns `Outcome<Guardrails>`.
209///
210/// A run's guardrails are always its project's: `repo_id`, when given,
211/// names that repository however it has moved since, and a pull
212/// request's working copy (`pulls/<pull id>`) stands for the repository
213/// the pull request is to.
214#[derive(Debug, Serialize, Deserialize)]
215pub struct RunGuardrailsArgs {
216 pub repo: RepoPath,
217 #[serde(default)]
218 pub repo_id: Option<String>,
219}
220
221/// Why g1t stopped a run by itself.
222#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum Halt {
225 /// It reached its cost cap.
226 Budget,
227 /// It reached its time cap.
228 Time,
229 /// Its sandbox looked like it was mining cryptocurrency: CPU pinned for
230 /// a long time with little I/O and no progress. Held for review.
231 Abuse,
232}
233
234impl Halt {
235 pub fn as_str(self) -> &'static str {
236 match self {
237 Halt::Budget => "budget",
238 Halt::Time => "time",
239 Halt::Abuse => "abuse",
240 }
241 }
242
243 pub fn parse(value: &str) -> Option<Halt> {
244 [Halt::Budget, Halt::Time, Halt::Abuse].into_iter().find(|halt| halt.as_str() == value)
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 fn level() -> GuardrailSettings {
253 GuardrailSettings::default()
254 }
255
256 #[test]
257 fn defaults_cap_every_kind_of_run() {
258 let defaults = Guardrails::defaults();
259 assert_eq!(defaults.minutes_for(RunKind::Implement), 90);
260 assert_eq!(defaults.minutes.len(), RunKind::ALL.len());
261 }
262
263 #[test]
264 fn nothing_set_inherits_everything() {
265 assert_eq!(Guardrails::merge(&level(), Some(&level())), Guardrails::defaults());
266 }
267
268 #[test]
269 fn a_project_overrides_its_workspace() {
270 let workspace = GuardrailSettings {
271 minutes: BTreeMap::from([("implement".to_owned(), 30), ("review".to_owned(), 20)]),
272 ..level()
273 };
274 let project = GuardrailSettings {
275 minutes: BTreeMap::from([("review".to_owned(), 15)]),
276 ..level()
277 };
278 let inherited = Guardrails::merge(&workspace, None);
279 assert_eq!(inherited.minutes_for(RunKind::Review), 20);
280 let effective = Guardrails::merge(&workspace, Some(&project));
281 // Inherited where the project says nothing.
282 assert_eq!(effective.minutes_for(RunKind::Implement), 30);
283 assert_eq!(effective.minutes_for(RunKind::Review), 15);
284 }
285
286 #[test]
287 fn levels_saved_with_network_rules_still_read_and_drop_them() {
288 // Network rules, command rules, deny patterns and a cost cap, from
289 // before, are ignored, and gone once the level is saved again.
290 let old: GuardrailSettings = serde_json::from_str(
291 r#"{"restrictNetwork":true,"registries":["npm"],"domains":["example.com"],
292 "workflowDomains":[{"domain":"api.cloudflare.com","workflows":["deploy.yml"],"environments":[]}],
293 "rules":{"sudo":false},"deny":["Bash(rm:*)"],"budgetUsd":2.5,"minutes":{"implement":45}}"#,
294 )
295 .unwrap();
296 assert_eq!(old.minutes, BTreeMap::from([("implement".to_owned(), 45)]));
297 let saved = serde_json::to_value(validate(old).unwrap()).unwrap();
298 assert_eq!(saved, serde_json::json!({ "minutes": { "implement": 45 }, "updatedBy": null, "updatedAt": null }));
299 // What a run got before reads as its time caps.
300 let old: Guardrails = serde_json::from_value(serde_json::json!({
301 "restrictNetwork": true, "registries": [], "domains": [], "hosts": ["g1t.sh"], "workflowDomains": [], "minutes": { "implement": 90 }
302 }))
303 .unwrap();
304 assert_eq!(old.minutes_for(RunKind::Implement), 90);
305 let sent = serde_json::to_value(Guardrails::defaults()).unwrap();
306 assert!(sent.get("restrictNetwork").is_none() && sent.get("hosts").is_none());
307 }
308
309 #[test]
310 fn domains_are_tidied_or_refused() {
311 assert_eq!(normalize_domain(" HTTPS://Api.Stripe.com/v1 ").unwrap(), "api.stripe.com");
312 assert_eq!(normalize_domain("*.example.com").unwrap(), "*.example.com");
313 assert_eq!(normalize_domain("example.com:8443").unwrap(), "example.com");
314 assert!(normalize_domain("localhost").is_err());
315 assert!(normalize_domain("*.*.com").is_err());
316 assert!(normalize_domain("exa mple.com").is_err());
317 assert!(normalize_domain("*").is_err());
318 }
319
320 #[test]
321 fn validation_refuses_bad_caps() {
322 assert!(
323 validate(GuardrailSettings {
324 minutes: BTreeMap::from([("implement".to_owned(), 0)]),
325 ..level()
326 })
327 .is_err()
328 );
329 assert!(
330 validate(GuardrailSettings {
331 minutes: BTreeMap::from([("lunch".to_owned(), 10)]),
332 ..level()
333 })
334 .is_err()
335 );
336 assert!(
337 validate(GuardrailSettings {
338 minutes: BTreeMap::from([("implement".to_owned(), MAX_MINUTES)]),
339 ..level()
340 })
341 .is_ok()
342 );
343 }
344}