Skip to content
678 linesCodeBlameRaw
1//! Guardrails: what a workspace lets its sandboxes do. Kept by the work
2//! service.
3//!
4//! A workspace sets defaults and each project may override them. Two kinds
5//! of rule come out of the two:
6//!
7//! - **Network**: which hosts a sandbox may reach. g1t's own hosts always,
8//! the package registries the project needs, and any domains listed.
9//! Everything else is refused at the sandbox's edge. Some domains can be
10//! opened to workflow jobs only.
11//! - **Time**: how long each kind of run may take before g1t stops it.
12//!
13//! Levels saved when guardrails also had command rules, deny patterns and
14//! a cost cap (for g1t's own agents, since removed) still read: those
15//! fields are ignored.
16//!
17//! Each `*Args` struct is the argument of the method of the same name,
18//! served at `POST /rpc/<method>`.
19
20use std::collections::BTreeMap;
21
22use serde::{Deserialize, Serialize};
23
24use crate::agents::RunKind;
25use crate::repos::RepoPath;
26use crate::{User, Viewer};
27
28/// g1t's own hosts. Always reachable: without them a sandbox could not
29/// clone, push or report.
30pub const G1T_HOSTS: &[&str] = &["g1t.sh", "api.g1t.sh", "mcp.g1t.sh"];
31
32/// A package registry, which a project turns on or off as one.
33#[derive(Clone, Copy, Debug)]
34pub struct Registry {
35 pub id: &'static str,
36 pub name: &'static str,
37 pub hosts: &'static [&'static str],
38}
39
40/// The registries a sandbox can be given, all on by default.
41pub const REGISTRIES: &[Registry] = &[
42 Registry {
43 id: "npm",
44 name: "npm and Yarn",
45 hosts: &["registry.npmjs.org", "registry.yarnpkg.com", "repo.yarnpkg.com"],
46 },
47 Registry {
48 id: "pypi",
49 name: "PyPI",
50 hosts: &["pypi.org", "files.pythonhosted.org"],
51 },
52 Registry {
53 id: "crates",
54 name: "crates.io and Rust toolchains",
55 hosts: &["crates.io", "index.crates.io", "static.crates.io", "static.rust-lang.org"],
56 },
57 Registry {
58 id: "go",
59 name: "Go module proxy",
60 hosts: &["proxy.golang.org", "sum.golang.org"],
61 },
62 Registry {
63 id: "github",
64 name: "GitHub downloads",
65 hosts: &[
66 "codeload.github.com",
67 "raw.githubusercontent.com",
68 "objects.githubusercontent.com",
69 ],
70 },
71];
72
73/// The most domains one level keeps.
74pub const MAX_DOMAINS: usize = 100;
75/// The most workflow-only domains one level keeps.
76pub const MAX_WORKFLOW_DOMAINS: usize = 50;
77const MAX_NAME_CHARS: usize = 255;
78/// The longest any run may be allowed to take, in minutes.
79pub const MAX_MINUTES: u32 = 240;
80
81/// One run's spend cap unless an owner or g1t staff set another, in
82/// micro-dollars: billing's spend cap per run.
83pub const DEFAULT_RUN_CAP_MICROS: i64 = 2_000_000;
84
85/// How long each kind of run may take unless the workspace says otherwise.
86pub fn default_minutes(kind: RunKind) -> u32 {
87 match kind {
88 RunKind::Implement => 90,
89 RunKind::Revise => 60,
90 RunKind::Review => 30,
91 RunKind::Answer => 20,
92 RunKind::Update => 45,
93 RunKind::Plan => 30,
94 RunKind::Checks => 45,
95 RunKind::Queue => 45,
96 RunKind::Mergecheck => 10,
97 }
98}
99
100/// What one level, the workspace or a project, sets. Anything left unset
101/// is inherited: a project from its workspace, a workspace from g1t's
102/// defaults. Domains add up across the two levels.
103#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase", default)]
105pub struct GuardrailSettings {
106 /// Whether sandboxes may reach only the allowed hosts.
107 pub restrict_network: Option<bool>,
108 /// The registries that are on, by id. Replaces the inherited list.
109 pub registries: Option<Vec<String>>,
110 /// More hosts to allow: `example.com`, or `*.example.com` for its
111 /// subdomains.
112 pub domains: Vec<String>,
113 /// Hosts only workflow jobs may reach, never agents: a deploy's API,
114 /// say. Each can be limited to some workflows and environments. They
115 /// add to the other level's.
116 pub workflow_domains: Vec<WorkflowDomain>,
117 /// How long a run may take, in minutes, by kind of run.
118 pub minutes: BTreeMap<String, u32>,
119 /// Username of whoever last changed this level.
120 pub updated_by: Option<String>,
121 /// RFC 3339.
122 pub updated_at: Option<String>,
123}
124
125/// A host that only workflow jobs may reach: jobs of a trusted run (not a
126/// pull request from a fork), of the workflows named, in the environments
127/// named. Agents, checks, the merge queue and g1t.page builds never do.
128#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
129#[serde(rename_all = "camelCase", default)]
130pub struct WorkflowDomain {
131 /// `api.example.com`, or `*.example.com` for its subdomains.
132 pub domain: String,
133 /// Workflow files by name, such as `deploy.yml`. Empty: any workflow.
134 pub workflows: Vec<String>,
135 /// The environments a job must name with `environment:`, such as
136 /// `production`. Empty: any job, whether it names one or not.
137 pub environments: Vec<String>,
138}
139
140impl WorkflowDomain {
141 /// Whether a job of `workflow` (its path or file name) in `environment`
142 /// may reach this domain. Names compare without regard to case.
143 pub fn applies_to(&self, workflow: &str, environment: Option<&str>) -> bool {
144 let file = workflow_file(workflow);
145 let workflow_ok = self.workflows.is_empty() || self.workflows.iter().any(|w| workflow_file(w).eq_ignore_ascii_case(file));
146 let environment_ok = self.environments.is_empty()
147 || environment.is_some_and(|env| self.environments.iter().any(|e| e.eq_ignore_ascii_case(env.trim())));
148 workflow_ok && environment_ok
149 }
150}
151
152/// A workflow's file name: `.g1t/workflows/deploy.yml` is `deploy.yml`.
153fn workflow_file(path: &str) -> &str {
154 let path = path.trim();
155 path.rsplit(['/', '\\']).next().unwrap_or(path)
156}
157
158/// The guardrails a run actually gets: g1t's defaults, then the
159/// workspace's, then the project's.
160#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub struct Guardrails {
163 pub restrict_network: bool,
164 pub registries: Vec<String>,
165 /// The domains listed at either level, workspace first.
166 pub domains: Vec<String>,
167 /// Every host a sandbox may reach: g1t's, the registries', the domains.
168 pub hosts: Vec<String>,
169 /// Hosts only some workflow jobs may reach, from both levels,
170 /// workspace first. Never in `hosts`.
171 #[serde(default)]
172 pub workflow_domains: Vec<WorkflowDomain>,
173 /// Every kind of run.
174 pub minutes: BTreeMap<String, u32>,
175}
176
177impl Guardrails {
178 /// g1t's defaults: network restricted to g1t and every registry, and a
179 /// time cap for each kind of run.
180 pub fn defaults() -> Self {
181 let mut defaults = Guardrails {
182 restrict_network: true,
183 registries: REGISTRIES.iter().map(|registry| registry.id.to_owned()).collect(),
184 domains: Vec::new(),
185 hosts: Vec::new(),
186 workflow_domains: Vec::new(),
187 minutes: RunKind::ALL
188 .into_iter()
189 .map(|kind| (kind.as_str().to_owned(), default_minutes(kind)))
190 .collect(),
191 };
192 defaults.hosts = defaults.allowed_hosts();
193 defaults
194 }
195
196 /// One level laid over what it inherits.
197 pub fn apply(mut self, level: &GuardrailSettings) -> Self {
198 if let Some(restrict) = level.restrict_network {
199 self.restrict_network = restrict;
200 }
201 if let Some(registries) = &level.registries {
202 self.registries = REGISTRIES
203 .iter()
204 .filter(|registry| registries.iter().any(|id| id == registry.id))
205 .map(|registry| registry.id.to_owned())
206 .collect();
207 }
208 for domain in &level.domains {
209 if !self.domains.contains(domain) {
210 self.domains.push(domain.clone());
211 }
212 }
213 for entry in &level.workflow_domains {
214 if !self.workflow_domains.contains(entry) {
215 self.workflow_domains.push(entry.clone());
216 }
217 }
218 for (kind, minutes) in &level.minutes {
219 if let Some(cap) = self.minutes.get_mut(kind) {
220 *cap = *minutes;
221 }
222 }
223 self.hosts = self.allowed_hosts();
224 self
225 }
226
227 /// The workspace's defaults with a project's overrides on top.
228 pub fn merge(workspace: &GuardrailSettings, project: Option<&GuardrailSettings>) -> Self {
229 let inherited = Guardrails::defaults().apply(workspace);
230 match project {
231 Some(project) => inherited.apply(project),
232 None => inherited,
233 }
234 }
235
236 fn allowed_hosts(&self) -> Vec<String> {
237 let mut hosts: Vec<String> = G1T_HOSTS.iter().map(|host| (*host).to_owned()).collect();
238 for registry in REGISTRIES {
239 if self.registries.iter().any(|id| id == registry.id) {
240 hosts.extend(registry.hosts.iter().map(|host| (*host).to_owned()));
241 }
242 }
243 for domain in &self.domains {
244 if !hosts.contains(domain) {
245 hosts.push(domain.clone());
246 }
247 }
248 hosts
249 }
250
251 /// The hosts a job of `workflow` (its path) in `environment` may
252 /// reach on top of `hosts`: the workflow-only domains that apply to
253 /// it. For workflow jobs of trusted runs only; the runner never adds
254 /// them for anything else.
255 pub fn workflow_hosts(&self, workflow: &str, environment: Option<&str>) -> Vec<String> {
256 let mut hosts: Vec<String> = Vec::new();
257 for entry in self.workflow_domains.iter().filter(|entry| entry.applies_to(workflow, environment)) {
258 if !hosts.contains(&entry.domain) {
259 hosts.push(entry.domain.clone());
260 }
261 }
262 hosts
263 }
264
265 /// The time cap of a kind of run, in minutes.
266 pub fn minutes_for(&self, kind: RunKind) -> u32 {
267 self.minutes
268 .get(kind.as_str())
269 .copied()
270 .unwrap_or_else(|| default_minutes(kind))
271 }
272}
273
274/// A domain as it is kept: lower case, no scheme, path or port, optionally
275/// `*.` for its subdomains. Refused if it is not a host name.
276pub fn normalize_domain(input: &str) -> Result<String, String> {
277 let mut domain = input.trim().to_lowercase();
278 for scheme in ["https://", "http://"] {
279 if let Some(rest) = domain.strip_prefix(scheme) {
280 domain = rest.to_owned();
281 }
282 }
283 if let Some(at) = domain.find(['/', ':']) {
284 domain.truncate(at);
285 }
286 let domain = domain.trim_end_matches('.').to_owned();
287 let bare = domain.strip_prefix("*.").unwrap_or(&domain);
288 let labels: Vec<&str> = bare.split('.').collect();
289 let valid = labels.len() >= 2
290 && bare.len() <= 253
291 && labels.iter().all(|label| {
292 !label.is_empty()
293 && label.len() <= 63
294 && !label.starts_with('-')
295 && !label.ends_with('-')
296 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
297 });
298 if valid {
299 Ok(domain)
300 } else {
301 Err(format!("{} is not a domain. Use a host name such as example.com, or *.example.com for its subdomains.", input.trim()))
302 }
303}
304
305/// One level's settings, checked and tidied before they are kept.
306pub fn validate(settings: GuardrailSettings) -> Result<GuardrailSettings, String> {
307 let mut domains = Vec::new();
308 for domain in &settings.domains {
309 if domain.trim().is_empty() {
310 continue;
311 }
312 let domain = normalize_domain(domain)?;
313 if !domains.contains(&domain) {
314 domains.push(domain);
315 }
316 }
317 if domains.len() > MAX_DOMAINS {
318 return Err(format!("At most {MAX_DOMAINS} domains can be listed."));
319 }
320 let mut workflow_domains: Vec<WorkflowDomain> = Vec::new();
321 for entry in &settings.workflow_domains {
322 if entry.domain.trim().is_empty() {
323 continue;
324 }
325 let entry = validate_workflow_domain(entry)?;
326 if !workflow_domains.contains(&entry) {
327 workflow_domains.push(entry);
328 }
329 }
330 if workflow_domains.len() > MAX_WORKFLOW_DOMAINS {
331 return Err(format!("At most {MAX_WORKFLOW_DOMAINS} workflow-only domains can be listed."));
332 }
333 let mut minutes = BTreeMap::new();
334 for (kind, cap) in settings.minutes {
335 if RunKind::parse(&kind).is_none() {
336 return Err(format!("{kind} is not a kind of run."));
337 }
338 if !(1..=MAX_MINUTES).contains(&cap) {
339 return Err(format!("A run's time cap is between 1 and {MAX_MINUTES} minutes."));
340 }
341 minutes.insert(kind, cap);
342 }
343 let registries = settings.registries.map(|ids| {
344 REGISTRIES
345 .iter()
346 .filter(|registry| ids.iter().any(|id| id == registry.id))
347 .map(|registry| registry.id.to_owned())
348 .collect()
349 });
350 Ok(GuardrailSettings {
351 restrict_network: settings.restrict_network,
352 registries,
353 domains,
354 workflow_domains,
355 minutes,
356 updated_by: settings.updated_by,
357 updated_at: settings.updated_at,
358 })
359}
360
361/// A workflow-only domain, tidied: its domain as `normalize_domain` keeps
362/// it, workflows as file names ending in `.yml` or `.yaml`, and
363/// environments as given, each list without repeats.
364pub fn validate_workflow_domain(entry: &WorkflowDomain) -> Result<WorkflowDomain, String> {
365 let domain = normalize_domain(&entry.domain)?;
366 let mut workflows: Vec<String> = Vec::new();
367 for workflow in entry.workflows.iter().map(|w| workflow_file(w).to_owned()).filter(|w| !w.is_empty()) {
368 let lower = workflow.to_lowercase();
369 let valid = (lower.ends_with(".yml") || lower.ends_with(".yaml"))
370 && workflow.chars().count() <= MAX_NAME_CHARS
371 && workflow.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
372 if !valid {
373 return Err(format!("{workflow} is not a workflow file. Name it as it is in .g1t/workflows, such as deploy.yml."));
374 }
375 if !workflows.iter().any(|w| w.eq_ignore_ascii_case(&workflow)) {
376 workflows.push(workflow);
377 }
378 }
379 let mut environments: Vec<String> = Vec::new();
380 for environment in entry.environments.iter().map(|e| e.trim().to_owned()).filter(|e| !e.is_empty()) {
381 if environment.chars().count() > MAX_NAME_CHARS || environment.contains(['\n', '\r']) || environment.contains("${{") {
382 return Err(format!("{environment} is not an environment name."));
383 }
384 if !environments.iter().any(|e| e.eq_ignore_ascii_case(&environment)) {
385 environments.push(environment);
386 }
387 }
388 Ok(WorkflowDomain { domain, workflows, environments })
389}
390
391/// A registry as the settings page shows it.
392#[derive(Clone, Debug, Serialize, Deserialize)]
393pub struct RegistryInfo {
394 pub id: String,
395 pub name: String,
396 pub hosts: Vec<String>,
397}
398
399/// Everything the settings pages show: each level as it was set, what
400/// each inherits, and what is in force.
401#[derive(Clone, Debug, Serialize, Deserialize)]
402#[serde(rename_all = "camelCase")]
403pub struct GuardrailsView {
404 pub workspace: GuardrailSettings,
405 /// None when no project was asked about.
406 pub project: Option<GuardrailSettings>,
407 pub defaults: Guardrails,
408 /// g1t's defaults with the workspace's: what a project inherits.
409 pub inherited: Guardrails,
410 /// What runs get: the project's, or with no project, the workspace's.
411 pub effective: Guardrails,
412 pub g1t_hosts: Vec<String>,
413 pub registries: Vec<RegistryInfo>,
414}
415
416impl GuardrailsView {
417 pub fn new(workspace: GuardrailSettings, project: Option<GuardrailSettings>) -> Self {
418 let inherited = Guardrails::merge(&workspace, None);
419 let effective = Guardrails::merge(&workspace, project.as_ref());
420 GuardrailsView {
421 workspace,
422 project,
423 defaults: Guardrails::defaults(),
424 inherited,
425 effective,
426 g1t_hosts: G1T_HOSTS.iter().map(|host| (*host).to_owned()).collect(),
427 registries: REGISTRIES
428 .iter()
429 .map(|registry| RegistryInfo {
430 id: registry.id.to_owned(),
431 name: registry.name.to_owned(),
432 hosts: registry.hosts.iter().map(|host| (*host).to_owned()).collect(),
433 })
434 .collect(),
435 }
436 }
437}
438
439/// `get_guardrails`: a workspace's guardrails, and with `repo`, that
440/// project's too. Members only. Returns `Outcome<GuardrailsView>`.
441#[derive(Debug, Serialize, Deserialize)]
442pub struct GetGuardrailsArgs {
443 pub viewer: Viewer,
444 pub workspace: String,
445 #[serde(default)]
446 pub repo: Option<RepoPath>,
447}
448
449/// `update_guardrails`: replaces one level's settings: the workspace's
450/// (owners only) or, with `repo`, that project's (members). Returns
451/// `Outcome<GuardrailsView>`.
452#[derive(Debug, Serialize, Deserialize)]
453pub struct UpdateGuardrailsArgs {
454 pub actor: User,
455 pub workspace: String,
456 #[serde(default)]
457 pub repo: Option<RepoPath>,
458 pub settings: GuardrailSettings,
459}
460
461/// `run_guardrails`: what a run in `repo` gets. For the runner service,
462/// which is trusted. Returns `Outcome<Guardrails>`.
463///
464/// A run's guardrails are always its project's: `repo_id`, when given,
465/// names that repository however it has moved since, and a pull
466/// request's working copy (`pulls/<pull id>`) stands for the repository
467/// the pull request is to.
468#[derive(Debug, Serialize, Deserialize)]
469pub struct RunGuardrailsArgs {
470 pub repo: RepoPath,
471 #[serde(default)]
472 pub repo_id: Option<String>,
473}
474
475/// Why g1t stopped a run by itself.
476#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
477#[serde(rename_all = "snake_case")]
478pub enum Halt {
479 /// It reached its cost cap.
480 Budget,
481 /// It reached its time cap.
482 Time,
483 /// Its sandbox looked like it was mining cryptocurrency: CPU pinned for
484 /// a long time with little I/O and no progress. Held for review.
485 Abuse,
486}
487
488impl Halt {
489 pub fn as_str(self) -> &'static str {
490 match self {
491 Halt::Budget => "budget",
492 Halt::Time => "time",
493 Halt::Abuse => "abuse",
494 }
495 }
496
497 pub fn parse(value: &str) -> Option<Halt> {
498 [Halt::Budget, Halt::Time, Halt::Abuse].into_iter().find(|halt| halt.as_str() == value)
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505
506 fn level() -> GuardrailSettings {
507 GuardrailSettings::default()
508 }
509
510 #[test]
511 fn defaults_restrict_to_g1t_and_every_registry() {
512 let defaults = Guardrails::defaults();
513 assert!(defaults.restrict_network);
514 assert!(defaults.hosts.iter().any(|host| host == "api.g1t.sh"));
515 assert!(defaults.hosts.iter().any(|host| host == "registry.npmjs.org"));
516 assert!(defaults.hosts.iter().any(|host| host == "codeload.github.com"));
517 assert!(!defaults.hosts.iter().any(|host| host == "github.com"));
518 assert!(!defaults.hosts.iter().any(|host| host == "models.g1t.sh"));
519 assert_eq!(defaults.minutes_for(RunKind::Implement), 90);
520 }
521
522 #[test]
523 fn nothing_set_inherits_everything() {
524 assert_eq!(Guardrails::merge(&level(), Some(&level())), Guardrails::defaults());
525 }
526
527 #[test]
528 fn a_project_overrides_its_workspace() {
529 let workspace = GuardrailSettings {
530 registries: Some(vec!["npm".into(), "pypi".into()]),
531 minutes: BTreeMap::from([("implement".to_owned(), 30)]),
532 ..level()
533 };
534 let project = GuardrailSettings {
535 registries: Some(vec!["crates".into()]),
536 ..level()
537 };
538 let inherited = Guardrails::merge(&workspace, None);
539 assert_eq!(inherited.registries, vec!["npm", "pypi"]);
540 assert!(inherited.hosts.iter().any(|host| host == "pypi.org"));
541 assert!(!inherited.hosts.iter().any(|host| host == "crates.io"));
542
543 let effective = Guardrails::merge(&workspace, Some(&project));
544 assert_eq!(effective.registries, vec!["crates"]);
545 assert!(effective.hosts.iter().any(|host| host == "crates.io"));
546 assert!(!effective.hosts.iter().any(|host| host == "pypi.org"));
547 // Inherited where the project says nothing.
548 assert_eq!(effective.minutes_for(RunKind::Implement), 30);
549 assert_eq!(effective.minutes_for(RunKind::Review), 30);
550 // g1t's own hosts can never be turned off.
551 assert!(effective.hosts.iter().any(|host| host == "g1t.sh"));
552 }
553
554 #[test]
555 fn domains_add_up() {
556 let workspace = GuardrailSettings { domains: vec!["api.stripe.com".into()], ..level() };
557 let project = GuardrailSettings { domains: vec!["*.example.com".into(), "api.stripe.com".into()], ..level() };
558 let effective = Guardrails::merge(&workspace, Some(&project));
559 assert_eq!(effective.domains, vec!["api.stripe.com", "*.example.com"]);
560 assert!(effective.hosts.iter().any(|host| host == "*.example.com"));
561 }
562
563 fn workflow_domain(domain: &str, workflows: &[&str], environments: &[&str]) -> WorkflowDomain {
564 WorkflowDomain {
565 domain: domain.into(),
566 workflows: workflows.iter().map(|w| (*w).to_owned()).collect(),
567 environments: environments.iter().map(|e| (*e).to_owned()).collect(),
568 }
569 }
570
571 #[test]
572 fn workflow_domains_are_never_hosts_and_reach_only_the_jobs_named() {
573 let workspace = GuardrailSettings {
574 workflow_domains: vec![workflow_domain("api.cloudflare.com", &["deploy.yml"], &["production"])],
575 ..level()
576 };
577 let project = GuardrailSettings {
578 workflow_domains: vec![
579 workflow_domain("api.cloudflare.com", &["deploy.yml"], &["production"]),
580 workflow_domain("*.example.com", &[], &[]),
581 ],
582 ..level()
583 };
584 let effective = Guardrails::merge(&workspace, Some(&project));
585 // Added up across the levels, once each, and never for agents.
586 assert_eq!(effective.workflow_domains.len(), 2);
587 assert!(!effective.hosts.iter().any(|host| host == "api.cloudflare.com" || host == "*.example.com"));
588 // deploy.yml's jobs in production, by path or name, in any case.
589 assert_eq!(effective.workflow_hosts(".g1t/workflows/deploy.yml", Some("production")), ["api.cloudflare.com", "*.example.com"]);
590 assert_eq!(effective.workflow_hosts("DEPLOY.yml", Some("Production")), ["api.cloudflare.com", "*.example.com"]);
591 // Another environment, none, or another workflow: only the open one.
592 assert_eq!(effective.workflow_hosts(".g1t/workflows/deploy.yml", Some("staging")), ["*.example.com"]);
593 assert_eq!(effective.workflow_hosts(".g1t/workflows/deploy.yml", None), ["*.example.com"]);
594 assert_eq!(effective.workflow_hosts(".g1t/workflows/ci.yml", Some("production")), ["*.example.com"]);
595 }
596
597 #[test]
598 fn workflow_domains_are_tidied_or_refused() {
599 let settings = validate(GuardrailSettings {
600 workflow_domains: vec![
601 workflow_domain(" HTTPS://API.Cloudflare.com/client/v4 ", &[".g1t/workflows/deploy.yml", "deploy.yml"], &[" production ", "Production"]),
602 workflow_domain("api.cloudflare.com", &["deploy.yml"], &["production"]),
603 workflow_domain(" ", &[], &[]),
604 ],
605 ..level()
606 })
607 .unwrap();
608 assert_eq!(settings.workflow_domains, vec![workflow_domain("api.cloudflare.com", &["deploy.yml"], &["production"])]);
609 let refused = |entry: WorkflowDomain| validate(GuardrailSettings { workflow_domains: vec![entry], ..level() }).is_err();
610 assert!(refused(workflow_domain("localhost", &[], &[])));
611 assert!(refused(workflow_domain("api.example.com", &["deploy"], &[])));
612 assert!(refused(workflow_domain("api.example.com", &["de ploy.yml"], &[])));
613 assert!(refused(workflow_domain("api.example.com", &[], &["${{ inputs.env }}"])));
614 let many = (0..=MAX_WORKFLOW_DOMAINS).map(|i| workflow_domain(&format!("h{i}.example.com"), &[], &[])).collect();
615 assert!(validate(GuardrailSettings { workflow_domains: many, ..level() }).is_err());
616 }
617
618 #[test]
619 fn levels_saved_before_workflow_domains_still_read() {
620 let old: GuardrailSettings = serde_json::from_str(r#"{"domains":["example.com"]}"#).unwrap();
621 assert!(old.workflow_domains.is_empty());
622 let old: Guardrails = serde_json::from_value(serde_json::json!({
623 "restrictNetwork": true, "registries": [], "domains": [], "hosts": [], "rules": {}, "deny": [], "budgetUsd": null, "minutes": {}
624 }))
625 .unwrap();
626 assert!(old.workflow_hosts("deploy.yml", Some("production")).is_empty());
627 // Command rules, deny patterns and a cost cap, from when g1t's own
628 // agents had them, are ignored.
629 let old: GuardrailSettings =
630 serde_json::from_str(r#"{"rules":{"sudo":false},"deny":["Bash(rm:*)"],"budgetUsd":2.5,"domains":["example.com"]}"#).unwrap();
631 assert_eq!(old.domains, vec!["example.com"]);
632 assert!(validate(old).is_ok());
633 }
634
635 #[test]
636 fn unrestricted_is_kept() {
637 let project = GuardrailSettings { restrict_network: Some(false), ..level() };
638 let effective = Guardrails::merge(&level(), Some(&project));
639 assert!(!effective.restrict_network);
640 }
641
642 #[test]
643 fn domains_are_tidied_or_refused() {
644 assert_eq!(normalize_domain(" HTTPS://Api.Stripe.com/v1 ").unwrap(), "api.stripe.com");
645 assert_eq!(normalize_domain("*.example.com").unwrap(), "*.example.com");
646 assert_eq!(normalize_domain("example.com:8443").unwrap(), "example.com");
647 assert!(normalize_domain("localhost").is_err());
648 assert!(normalize_domain("*.*.com").is_err());
649 assert!(normalize_domain("exa mple.com").is_err());
650 assert!(normalize_domain("*").is_err());
651 }
652
653 #[test]
654 fn validation_clamps_and_drops_unknowns() {
655 let settings = validate(GuardrailSettings {
656 registries: Some(vec!["npm".into(), "nonsense".into()]),
657 domains: vec!["Example.com".into(), "example.com".into(), " ".into()],
658 ..level()
659 })
660 .unwrap();
661 assert_eq!(settings.registries, Some(vec!["npm".to_owned()]));
662 assert_eq!(settings.domains, vec!["example.com"]);
663 assert!(
664 validate(GuardrailSettings {
665 minutes: BTreeMap::from([("implement".to_owned(), 0)]),
666 ..level()
667 })
668 .is_err()
669 );
670 assert!(
671 validate(GuardrailSettings {
672 minutes: BTreeMap::from([("lunch".to_owned(), 10)]),
673 ..level()
674 })
675 .is_err()
676 );
677 }
678}