g1t/crates/contracts/src/guardrails.rs

846 lines33,945 bytesCodeBlame
1//! Guardrails: what a workspace lets its agents do in a sandbox. Kept by
2//! the work service.
3//!
4//! A workspace sets defaults and each project may override them. Three
5//! kinds 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.
10//! - **Commands**: what the agent's harness refuses to run: built-in rules
11//! that can be turned off, and the workspace's own deny patterns.
12//! - **Caps**: the most one run may cost, and how long each kind of run
13//! may take, before g1t stops it.
14//!
15//! Each `*Args` struct is the argument of the method of the same name,
16//! served at `POST /rpc/<method>`.
17
18use std::collections::BTreeMap;
19
20use serde::{Deserialize, Serialize};
21
22use crate::agents::RunKind;
23use crate::repos::RepoPath;
24use crate::{User, Viewer};
25
26/// g1t's own hosts. Always reachable: without them a sandbox could not
27/// clone, push, report or reach its model.
28pub const G1T_HOSTS: &[&str] = &["g1t.sh", "api.g1t.sh", "models.g1t.sh", "mcp.g1t.sh"];
29
30/// A package registry, which a project turns on or off as one.
31#[derive(Clone, Copy, Debug)]
32pub struct Registry {
33 pub id: &'static str,
34 pub name: &'static str,
35 pub hosts: &'static [&'static str],
36}
37
38/// The registries a sandbox can be given, all on by default.
39pub const REGISTRIES: &[Registry] = &[
40 Registry {
41 id: "npm",
42 name: "npm and Yarn",
43 hosts: &["registry.npmjs.org", "registry.yarnpkg.com", "repo.yarnpkg.com"],
44 },
45 Registry {
46 id: "pypi",
47 name: "PyPI",
48 hosts: &["pypi.org", "files.pythonhosted.org"],
49 },
50 Registry {
51 id: "crates",
52 name: "crates.io and Rust toolchains",
53 hosts: &["crates.io", "index.crates.io", "static.crates.io", "static.rust-lang.org"],
54 },
55 Registry {
56 id: "go",
57 name: "Go module proxy",
58 hosts: &["proxy.golang.org", "sum.golang.org"],
59 },
60 Registry {
61 id: "github",
62 name: "GitHub downloads",
63 hosts: &[
64 "codeload.github.com",
65 "raw.githubusercontent.com",
66 "objects.githubusercontent.com",
67 ],
68 },
69];
70
71/// A command rule the harness enforces, which can be turned off.
72#[derive(Clone, Copy, Debug)]
73pub struct CommandRule {
74 pub id: &'static str,
75 pub title: &'static str,
76 pub about: &'static str,
77}
78
79/// The built-in command rules, all on by default.
80pub const COMMAND_RULES: &[CommandRule] = &[
81 CommandRule {
82 id: "force_push",
83 title: "No force-pushing",
84 about: "git push with --force, --force-with-lease, --mirror, a + refspec, or deleting a branch.",
85 },
86 CommandRule {
87 id: "rewrite_default_branch",
88 title: "No rewriting the default branch",
89 about: "Pushing to the default branch, moving or deleting it with git branch or git update-ref, and git filter-branch, filter-repo or replace.",
90 },
91 CommandRule {
92 id: "outside_workspace",
93 title: "No reading files outside the project",
94 about: "File tools may use the checked-out project, /tmp and package caches only. Shell commands may not touch g1t's own files or other processes' environments.",
95 },
96 CommandRule {
97 id: "print_env",
98 title: "No printing the environment",
99 about: "env, printenv, export -p, set, /proc/*/environ, and echoing variables that look like keys or tokens.",
100 },
101 CommandRule {
102 id: "sudo",
103 title: "No sudo",
104 about: "sudo, su and doas are refused, and the sandbox gives up root before the agent starts.",
105 },
106];
107
108/// The most deny patterns or domains one level keeps.
109pub const MAX_PATTERNS: usize = 50;
110pub const MAX_DOMAINS: usize = 100;
111/// The most workflow-only domains one level keeps.
112pub const MAX_WORKFLOW_DOMAINS: usize = 50;
113const MAX_NAME_CHARS: usize = 255;
114const MAX_PATTERN_CHARS: usize = 200;
115/// The most a run may be allowed to cost, in US dollars.
116pub const MAX_BUDGET_USD: f64 = 100.0;
117/// The longest any run may be allowed to take, in minutes.
118pub const MAX_MINUTES: u32 = 240;
119
120/// The cost cap on one run unless the workspace sets another, in US dollars.
121pub const DEFAULT_BUDGET_USD: f64 = 5.0;
122
123/// How long each kind of run may take unless the workspace says otherwise.
124pub fn default_minutes(kind: RunKind) -> u32 {
125 match kind {
126 RunKind::Implement => 90,
127 RunKind::Revise => 60,
128 RunKind::Review => 30,
129 RunKind::Answer => 20,
130 RunKind::Update => 45,
131 RunKind::Plan => 30,
132 RunKind::Checks => 45,
133 RunKind::Queue => 45,
134 RunKind::Mergecheck => 10,
135 }
136}
137
138/// What one level, the workspace or a project, sets. Anything left unset
139/// is inherited: a project from its workspace, a workspace from g1t's
140/// defaults. Domains and deny patterns add up across the two levels.
141#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
142#[serde(rename_all = "camelCase", default)]
143pub struct GuardrailSettings {
144 /// Whether sandboxes may reach only the allowed hosts.
145 pub restrict_network: Option<bool>,
146 /// The registries that are on, by id. Replaces the inherited list.
147 pub registries: Option<Vec<String>>,
148 /// More hosts to allow: `example.com`, or `*.example.com` for its
149 /// subdomains.
150 pub domains: Vec<String>,
151 /// Hosts only workflow jobs may reach, never agents: a deploy's API,
152 /// say. Each can be limited to some workflows and environments. They
153 /// add to the other level's.
154 pub workflow_domains: Vec<WorkflowDomain>,
155 /// Built-in command rules turned on or off, by id.
156 pub rules: BTreeMap<String, bool>,
157 /// Commands and tools to refuse, as permission rules:
158 /// `Bash(terraform apply:*)`, `Read(/etc/**)`, `WebFetch`.
159 pub deny: Vec<String>,
160 /// The most a run may cost, in US dollars. Zero means no cap.
161 pub budget_usd: Option<f64>,
162 /// How long a run may take, in minutes, by kind of run.
163 pub minutes: BTreeMap<String, u32>,
164 /// Username of whoever last changed this level.
165 pub updated_by: Option<String>,
166 /// RFC 3339.
167 pub updated_at: Option<String>,
168}
169
170/// A host that only workflow jobs may reach: jobs of a trusted run (not a
171/// pull request from a fork), of the workflows named, in the environments
172/// named. Agents, checks, the merge queue and g1t.page builds never do.
173#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "camelCase", default)]
175pub struct WorkflowDomain {
176 /// `api.example.com`, or `*.example.com` for its subdomains.
177 pub domain: String,
178 /// Workflow files by name, such as `deploy.yml`. Empty: any workflow.
179 pub workflows: Vec<String>,
180 /// The environments a job must name with `environment:`, such as
181 /// `production`. Empty: any job, whether it names one or not.
182 pub environments: Vec<String>,
183}
184
185impl WorkflowDomain {
186 /// Whether a job of `workflow` (its path or file name) in `environment`
187 /// may reach this domain. Names compare without regard to case.
188 pub fn applies_to(&self, workflow: &str, environment: Option<&str>) -> bool {
189 let file = workflow_file(workflow);
190 let workflow_ok = self.workflows.is_empty() || self.workflows.iter().any(|w| workflow_file(w).eq_ignore_ascii_case(file));
191 let environment_ok = self.environments.is_empty()
192 || environment.is_some_and(|env| self.environments.iter().any(|e| e.eq_ignore_ascii_case(env.trim())));
193 workflow_ok && environment_ok
194 }
195}
196
197/// A workflow's file name: `.g1t/workflows/deploy.yml` is `deploy.yml`.
198fn workflow_file(path: &str) -> &str {
199 let path = path.trim();
200 path.rsplit(['/', '\\']).next().unwrap_or(path)
201}
202
203/// The guardrails a run actually gets: g1t's defaults, then the
204/// workspace's, then the project's.
205#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub struct Guardrails {
208 pub restrict_network: bool,
209 pub registries: Vec<String>,
210 /// The domains listed at either level, workspace first.
211 pub domains: Vec<String>,
212 /// Every host a sandbox may reach: g1t's, the registries', the domains.
213 pub hosts: Vec<String>,
214 /// Hosts only some workflow jobs may reach, from both levels,
215 /// workspace first. Never in `hosts`.
216 #[serde(default)]
217 pub workflow_domains: Vec<WorkflowDomain>,
218 /// Every built-in rule, on or off.
219 pub rules: BTreeMap<String, bool>,
220 /// The deny patterns of both levels, workspace first.
221 pub deny: Vec<String>,
222 /// None: no cap.
223 pub budget_usd: Option<f64>,
224 /// Every kind of run.
225 pub minutes: BTreeMap<String, u32>,
226}
227
228impl Guardrails {
229 /// g1t's defaults: network restricted to g1t and every registry, every
230 /// command rule on, a cost cap and a time cap for each kind of run.
231 pub fn defaults() -> Self {
232 let mut defaults = Guardrails {
233 restrict_network: true,
234 registries: REGISTRIES.iter().map(|registry| registry.id.to_owned()).collect(),
235 domains: Vec::new(),
236 hosts: Vec::new(),
237 workflow_domains: Vec::new(),
238 rules: COMMAND_RULES.iter().map(|rule| (rule.id.to_owned(), true)).collect(),
239 deny: Vec::new(),
240 budget_usd: Some(DEFAULT_BUDGET_USD),
241 minutes: RunKind::ALL
242 .into_iter()
243 .map(|kind| (kind.as_str().to_owned(), default_minutes(kind)))
244 .collect(),
245 };
246 defaults.hosts = defaults.allowed_hosts();
247 defaults
248 }
249
250 /// One level laid over what it inherits.
251 pub fn apply(mut self, level: &GuardrailSettings) -> Self {
252 if let Some(restrict) = level.restrict_network {
253 self.restrict_network = restrict;
254 }
255 if let Some(registries) = &level.registries {
256 self.registries = REGISTRIES
257 .iter()
258 .filter(|registry| registries.iter().any(|id| id == registry.id))
259 .map(|registry| registry.id.to_owned())
260 .collect();
261 }
262 for domain in &level.domains {
263 if !self.domains.contains(domain) {
264 self.domains.push(domain.clone());
265 }
266 }
267 for entry in &level.workflow_domains {
268 if !self.workflow_domains.contains(entry) {
269 self.workflow_domains.push(entry.clone());
270 }
271 }
272 for (id, on) in &level.rules {
273 if let Some(rule) = self.rules.get_mut(id) {
274 *rule = *on;
275 }
276 }
277 for pattern in &level.deny {
278 if !self.deny.contains(pattern) {
279 self.deny.push(pattern.clone());
280 }
281 }
282 if let Some(budget) = level.budget_usd {
283 self.budget_usd = (budget > 0.0).then_some(budget);
284 }
285 for (kind, minutes) in &level.minutes {
286 if let Some(cap) = self.minutes.get_mut(kind) {
287 *cap = *minutes;
288 }
289 }
290 self.hosts = self.allowed_hosts();
291 self
292 }
293
294 /// The workspace's defaults with a project's overrides on top.
295 pub fn merge(workspace: &GuardrailSettings, project: Option<&GuardrailSettings>) -> Self {
296 let inherited = Guardrails::defaults().apply(workspace);
297 match project {
298 Some(project) => inherited.apply(project),
299 None => inherited,
300 }
301 }
302
303 fn allowed_hosts(&self) -> Vec<String> {
304 let mut hosts: Vec<String> = G1T_HOSTS.iter().map(|host| (*host).to_owned()).collect();
305 for registry in REGISTRIES {
306 if self.registries.iter().any(|id| id == registry.id) {
307 hosts.extend(registry.hosts.iter().map(|host| (*host).to_owned()));
308 }
309 }
310 for domain in &self.domains {
311 if !hosts.contains(domain) {
312 hosts.push(domain.clone());
313 }
314 }
315 hosts
316 }
317
318 /// The hosts a job of `workflow` (its path) in `environment` may
319 /// reach on top of `hosts`: the workflow-only domains that apply to
320 /// it. For workflow jobs of trusted runs only; the runner never adds
321 /// them for anything else.
322 pub fn workflow_hosts(&self, workflow: &str, environment: Option<&str>) -> Vec<String> {
323 let mut hosts: Vec<String> = Vec::new();
324 for entry in self.workflow_domains.iter().filter(|entry| entry.applies_to(workflow, environment)) {
325 if !hosts.contains(&entry.domain) {
326 hosts.push(entry.domain.clone());
327 }
328 }
329 hosts
330 }
331
332 /// The time cap of a kind of run, in minutes.
333 pub fn minutes_for(&self, kind: RunKind) -> u32 {
334 self.minutes
335 .get(kind.as_str())
336 .copied()
337 .unwrap_or_else(|| default_minutes(kind))
338 }
339}
340
341/// A domain as it is kept: lower case, no scheme, path or port, optionally
342/// `*.` for its subdomains. Refused if it is not a host name.
343pub fn normalize_domain(input: &str) -> Result<String, String> {
344 let mut domain = input.trim().to_lowercase();
345 for scheme in ["https://", "http://"] {
346 if let Some(rest) = domain.strip_prefix(scheme) {
347 domain = rest.to_owned();
348 }
349 }
350 if let Some(at) = domain.find(['/', ':']) {
351 domain.truncate(at);
352 }
353 let domain = domain.trim_end_matches('.').to_owned();
354 let bare = domain.strip_prefix("*.").unwrap_or(&domain);
355 let labels: Vec<&str> = bare.split('.').collect();
356 let valid = labels.len() >= 2
357 && bare.len() <= 253
358 && labels.iter().all(|label| {
359 !label.is_empty()
360 && label.len() <= 63
361 && !label.starts_with('-')
362 && !label.ends_with('-')
363 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
364 });
365 if valid {
366 Ok(domain)
367 } else {
368 Err(format!("{} is not a domain. Use a host name such as example.com, or *.example.com for its subdomains.", input.trim()))
369 }
370}
371
372/// A deny pattern as it is kept: a permission rule such as
373/// `Bash(terraform apply:*)`. Plain text is taken as the start of a shell
374/// command: `rm -rf` becomes `Bash(rm -rf:*)`.
375pub fn normalize_pattern(input: &str) -> Result<String, String> {
376 let pattern = input.trim();
377 if pattern.is_empty() || pattern.chars().count() > MAX_PATTERN_CHARS || pattern.contains('\n') {
378 return Err(format!("A deny pattern is one line of at most {MAX_PATTERN_CHARS} characters."));
379 }
380 let tool_end = pattern.find('(').unwrap_or(pattern.len());
381 let tool = &pattern[..tool_end];
382 let is_rule = !tool.is_empty()
383 && tool.chars().next().is_some_and(|c| c.is_ascii_uppercase())
384 && tool.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
385 && (tool_end == pattern.len() || (pattern.ends_with(')') && pattern.len() > tool_end + 2));
386 if is_rule {
387 return Ok(pattern.to_owned());
388 }
389 if pattern.contains(['(', ')']) {
390 return Err(format!(
391 "{pattern} is not a rule. Write a tool and what to refuse, such as Bash(terraform apply:*), or just the start of a command."
392 ));
393 }
394 Ok(format!("Bash({pattern}:*)"))
395}
396
397/// One level's settings, checked and tidied before they are kept.
398pub fn validate(settings: GuardrailSettings) -> Result<GuardrailSettings, String> {
399 let mut domains = Vec::new();
400 for domain in &settings.domains {
401 if domain.trim().is_empty() {
402 continue;
403 }
404 let domain = normalize_domain(domain)?;
405 if !domains.contains(&domain) {
406 domains.push(domain);
407 }
408 }
409 if domains.len() > MAX_DOMAINS {
410 return Err(format!("At most {MAX_DOMAINS} domains can be listed."));
411 }
412 let mut workflow_domains: Vec<WorkflowDomain> = Vec::new();
413 for entry in &settings.workflow_domains {
414 if entry.domain.trim().is_empty() {
415 continue;
416 }
417 let entry = validate_workflow_domain(entry)?;
418 if !workflow_domains.contains(&entry) {
419 workflow_domains.push(entry);
420 }
421 }
422 if workflow_domains.len() > MAX_WORKFLOW_DOMAINS {
423 return Err(format!("At most {MAX_WORKFLOW_DOMAINS} workflow-only domains can be listed."));
424 }
425 let mut deny = Vec::new();
426 for pattern in &settings.deny {
427 if pattern.trim().is_empty() {
428 continue;
429 }
430 let pattern = normalize_pattern(pattern)?;
431 if !deny.contains(&pattern) {
432 deny.push(pattern);
433 }
434 }
435 if deny.len() > MAX_PATTERNS {
436 return Err(format!("At most {MAX_PATTERNS} deny patterns can be listed."));
437 }
438 if let Some(budget) = settings.budget_usd
439 && (!budget.is_finite() || !(0.0..=MAX_BUDGET_USD).contains(&budget))
440 {
441 return Err(format!("A run's cost cap is between $0 (no cap) and ${MAX_BUDGET_USD:.0}."));
442 }
443 let mut minutes = BTreeMap::new();
444 for (kind, cap) in settings.minutes {
445 if RunKind::parse(&kind).is_none() {
446 return Err(format!("{kind} is not a kind of run."));
447 }
448 if !(1..=MAX_MINUTES).contains(&cap) {
449 return Err(format!("A run's time cap is between 1 and {MAX_MINUTES} minutes."));
450 }
451 minutes.insert(kind, cap);
452 }
453 let rules = settings
454 .rules
455 .into_iter()
456 .filter(|(id, _)| COMMAND_RULES.iter().any(|rule| rule.id == id))
457 .collect();
458 let registries = settings.registries.map(|ids| {
459 REGISTRIES
460 .iter()
461 .filter(|registry| ids.iter().any(|id| id == registry.id))
462 .map(|registry| registry.id.to_owned())
463 .collect()
464 });
465 Ok(GuardrailSettings {
466 restrict_network: settings.restrict_network,
467 registries,
468 domains,
469 workflow_domains,
470 rules,
471 deny,
472 budget_usd: settings.budget_usd,
473 minutes,
474 updated_by: settings.updated_by,
475 updated_at: settings.updated_at,
476 })
477}
478
479/// A workflow-only domain, tidied: its domain as `normalize_domain` keeps
480/// it, workflows as file names ending in `.yml` or `.yaml`, and
481/// environments as given, each list without repeats.
482pub fn validate_workflow_domain(entry: &WorkflowDomain) -> Result<WorkflowDomain, String> {
483 let domain = normalize_domain(&entry.domain)?;
484 let mut workflows: Vec<String> = Vec::new();
485 for workflow in entry.workflows.iter().map(|w| workflow_file(w).to_owned()).filter(|w| !w.is_empty()) {
486 let lower = workflow.to_lowercase();
487 let valid = (lower.ends_with(".yml") || lower.ends_with(".yaml"))
488 && workflow.chars().count() <= MAX_NAME_CHARS
489 && workflow.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
490 if !valid {
491 return Err(format!("{workflow} is not a workflow file. Name it as it is in .g1t/workflows, such as deploy.yml."));
492 }
493 if !workflows.iter().any(|w| w.eq_ignore_ascii_case(&workflow)) {
494 workflows.push(workflow);
495 }
496 }
497 let mut environments: Vec<String> = Vec::new();
498 for environment in entry.environments.iter().map(|e| e.trim().to_owned()).filter(|e| !e.is_empty()) {
499 if environment.chars().count() > MAX_NAME_CHARS || environment.contains(['\n', '\r']) || environment.contains("${{") {
500 return Err(format!("{environment} is not an environment name."));
501 }
502 if !environments.iter().any(|e| e.eq_ignore_ascii_case(&environment)) {
503 environments.push(environment);
504 }
505 }
506 Ok(WorkflowDomain { domain, workflows, environments })
507}
508
509/// A registry as the settings page shows it.
510#[derive(Clone, Debug, Serialize, Deserialize)]
511pub struct RegistryInfo {
512 pub id: String,
513 pub name: String,
514 pub hosts: Vec<String>,
515}
516
517/// A command rule as the settings page shows it.
518#[derive(Clone, Debug, Serialize, Deserialize)]
519pub struct RuleInfo {
520 pub id: String,
521 pub title: String,
522 pub about: String,
523}
524
525/// Everything the settings pages show: each level as it was set, what
526/// each inherits, and what is in force.
527#[derive(Clone, Debug, Serialize, Deserialize)]
528#[serde(rename_all = "camelCase")]
529pub struct GuardrailsView {
530 pub workspace: GuardrailSettings,
531 /// None when no project was asked about.
532 pub project: Option<GuardrailSettings>,
533 pub defaults: Guardrails,
534 /// g1t's defaults with the workspace's: what a project inherits.
535 pub inherited: Guardrails,
536 /// What runs get: the project's, or with no project, the workspace's.
537 pub effective: Guardrails,
538 pub g1t_hosts: Vec<String>,
539 pub registries: Vec<RegistryInfo>,
540 pub rules: Vec<RuleInfo>,
541}
542
543impl GuardrailsView {
544 pub fn new(workspace: GuardrailSettings, project: Option<GuardrailSettings>) -> Self {
545 let inherited = Guardrails::merge(&workspace, None);
546 let effective = Guardrails::merge(&workspace, project.as_ref());
547 GuardrailsView {
548 workspace,
549 project,
550 defaults: Guardrails::defaults(),
551 inherited,
552 effective,
553 g1t_hosts: G1T_HOSTS.iter().map(|host| (*host).to_owned()).collect(),
554 registries: REGISTRIES
555 .iter()
556 .map(|registry| RegistryInfo {
557 id: registry.id.to_owned(),
558 name: registry.name.to_owned(),
559 hosts: registry.hosts.iter().map(|host| (*host).to_owned()).collect(),
560 })
561 .collect(),
562 rules: COMMAND_RULES
563 .iter()
564 .map(|rule| RuleInfo {
565 id: rule.id.to_owned(),
566 title: rule.title.to_owned(),
567 about: rule.about.to_owned(),
568 })
569 .collect(),
570 }
571 }
572}
573
574/// `get_guardrails`: a workspace's guardrails, and with `repo`, that
575/// project's too. Members only. Returns `Outcome<GuardrailsView>`.
576#[derive(Debug, Serialize, Deserialize)]
577pub struct GetGuardrailsArgs {
578 pub viewer: Viewer,
579 pub workspace: String,
580 #[serde(default)]
581 pub repo: Option<RepoPath>,
582}
583
584/// `update_guardrails`: replaces one level's settings: the workspace's
585/// (owners only) or, with `repo`, that project's (members). Returns
586/// `Outcome<GuardrailsView>`.
587#[derive(Debug, Serialize, Deserialize)]
588pub struct UpdateGuardrailsArgs {
589 pub actor: User,
590 pub workspace: String,
591 #[serde(default)]
592 pub repo: Option<RepoPath>,
593 pub settings: GuardrailSettings,
594}
595
596/// `run_guardrails`: what a run in `repo` gets. For the runner service,
597/// which is trusted. Returns `Outcome<Guardrails>`.
598///
599/// A run's guardrails are always its project's: `repo_id`, when given,
600/// names that repository however it has moved since, and a pull
601/// request's working copy (`pulls/<pull id>`) stands for the repository
602/// the pull request is to.
603#[derive(Debug, Serialize, Deserialize)]
604pub struct RunGuardrailsArgs {
605 pub repo: RepoPath,
606 #[serde(default)]
607 pub repo_id: Option<String>,
608}
609
610/// Why g1t stopped a run by itself.
611#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
612#[serde(rename_all = "snake_case")]
613pub enum Halt {
614 /// It reached its cost cap.
615 Budget,
616 /// It reached its time cap.
617 Time,
618 /// Its sandbox looked like it was mining cryptocurrency: CPU pinned for
619 /// a long time with little I/O and no progress. Held for review.
620 Abuse,
621}
622
623impl Halt {
624 pub fn as_str(self) -> &'static str {
625 match self {
626 Halt::Budget => "budget",
627 Halt::Time => "time",
628 Halt::Abuse => "abuse",
629 }
630 }
631
632 pub fn parse(value: &str) -> Option<Halt> {
633 [Halt::Budget, Halt::Time, Halt::Abuse].into_iter().find(|halt| halt.as_str() == value)
634 }
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640
641 fn level() -> GuardrailSettings {
642 GuardrailSettings::default()
643 }
644
645 #[test]
646 fn defaults_restrict_to_g1t_and_every_registry() {
647 let defaults = Guardrails::defaults();
648 assert!(defaults.restrict_network);
649 assert!(defaults.hosts.iter().any(|host| host == "api.g1t.sh"));
650 assert!(defaults.hosts.iter().any(|host| host == "registry.npmjs.org"));
651 assert!(defaults.hosts.iter().any(|host| host == "codeload.github.com"));
652 assert!(!defaults.hosts.iter().any(|host| host == "github.com"));
653 assert!(defaults.rules.values().all(|on| *on));
654 assert_eq!(defaults.budget_usd, Some(DEFAULT_BUDGET_USD));
655 assert_eq!(defaults.minutes_for(RunKind::Implement), 90);
656 }
657
658 #[test]
659 fn nothing_set_inherits_everything() {
660 assert_eq!(Guardrails::merge(&level(), Some(&level())), Guardrails::defaults());
661 }
662
663 #[test]
664 fn a_project_overrides_its_workspace() {
665 let workspace = GuardrailSettings {
666 registries: Some(vec!["npm".into(), "pypi".into()]),
667 budget_usd: Some(2.0),
668 rules: BTreeMap::from([("sudo".to_owned(), false)]),
669 minutes: BTreeMap::from([("implement".to_owned(), 30)]),
670 ..level()
671 };
672 let project = GuardrailSettings {
673 registries: Some(vec!["crates".into()]),
674 budget_usd: Some(8.0),
675 rules: BTreeMap::from([("sudo".to_owned(), true), ("print_env".to_owned(), false)]),
676 ..level()
677 };
678 let inherited = Guardrails::merge(&workspace, None);
679 assert_eq!(inherited.registries, vec!["npm", "pypi"]);
680 assert_eq!(inherited.budget_usd, Some(2.0));
681 assert!(!inherited.rules["sudo"]);
682 assert!(inherited.hosts.iter().any(|host| host == "pypi.org"));
683 assert!(!inherited.hosts.iter().any(|host| host == "crates.io"));
684
685 let effective = Guardrails::merge(&workspace, Some(&project));
686 assert_eq!(effective.registries, vec!["crates"]);
687 assert!(effective.hosts.iter().any(|host| host == "crates.io"));
688 assert!(!effective.hosts.iter().any(|host| host == "pypi.org"));
689 assert_eq!(effective.budget_usd, Some(8.0));
690 assert!(effective.rules["sudo"]);
691 assert!(!effective.rules["print_env"]);
692 // Inherited where the project says nothing.
693 assert_eq!(effective.minutes_for(RunKind::Implement), 30);
694 assert_eq!(effective.minutes_for(RunKind::Review), 30);
695 // g1t's own hosts can never be turned off.
696 assert!(effective.hosts.iter().any(|host| host == "g1t.sh"));
697 }
698
699 #[test]
700 fn domains_and_deny_patterns_add_up() {
701 let workspace = GuardrailSettings {
702 domains: vec!["api.stripe.com".into()],
703 deny: vec!["Bash(terraform apply:*)".into()],
704 ..level()
705 };
706 let project = GuardrailSettings {
707 domains: vec!["*.example.com".into(), "api.stripe.com".into()],
708 deny: vec!["Bash(kubectl:*)".into()],
709 ..level()
710 };
711 let effective = Guardrails::merge(&workspace, Some(&project));
712 assert_eq!(effective.domains, vec!["api.stripe.com", "*.example.com"]);
713 assert_eq!(effective.deny, vec!["Bash(terraform apply:*)", "Bash(kubectl:*)"]);
714 assert!(effective.hosts.iter().any(|host| host == "*.example.com"));
715 }
716
717 fn workflow_domain(domain: &str, workflows: &[&str], environments: &[&str]) -> WorkflowDomain {
718 WorkflowDomain {
719 domain: domain.into(),
720 workflows: workflows.iter().map(|w| (*w).to_owned()).collect(),
721 environments: environments.iter().map(|e| (*e).to_owned()).collect(),
722 }
723 }
724
725 #[test]
726 fn workflow_domains_are_never_hosts_and_reach_only_the_jobs_named() {
727 let workspace = GuardrailSettings {
728 workflow_domains: vec![workflow_domain("api.cloudflare.com", &["deploy.yml"], &["production"])],
729 ..level()
730 };
731 let project = GuardrailSettings {
732 workflow_domains: vec![
733 workflow_domain("api.cloudflare.com", &["deploy.yml"], &["production"]),
734 workflow_domain("*.example.com", &[], &[]),
735 ],
736 ..level()
737 };
738 let effective = Guardrails::merge(&workspace, Some(&project));
739 // Added up across the levels, once each, and never for agents.
740 assert_eq!(effective.workflow_domains.len(), 2);
741 assert!(!effective.hosts.iter().any(|host| host == "api.cloudflare.com" || host == "*.example.com"));
742 // deploy.yml's jobs in production, by path or name, in any case.
743 assert_eq!(effective.workflow_hosts(".g1t/workflows/deploy.yml", Some("production")), ["api.cloudflare.com", "*.example.com"]);
744 assert_eq!(effective.workflow_hosts("DEPLOY.yml", Some("Production")), ["api.cloudflare.com", "*.example.com"]);
745 // Another environment, none, or another workflow: only the open one.
746 assert_eq!(effective.workflow_hosts(".g1t/workflows/deploy.yml", Some("staging")), ["*.example.com"]);
747 assert_eq!(effective.workflow_hosts(".g1t/workflows/deploy.yml", None), ["*.example.com"]);
748 assert_eq!(effective.workflow_hosts(".g1t/workflows/ci.yml", Some("production")), ["*.example.com"]);
749 }
750
751 #[test]
752 fn workflow_domains_are_tidied_or_refused() {
753 let settings = validate(GuardrailSettings {
754 workflow_domains: vec![
755 workflow_domain(" HTTPS://API.Cloudflare.com/client/v4 ", &[".g1t/workflows/deploy.yml", "deploy.yml"], &[" production ", "Production"]),
756 workflow_domain("api.cloudflare.com", &["deploy.yml"], &["production"]),
757 workflow_domain(" ", &[], &[]),
758 ],
759 ..level()
760 })
761 .unwrap();
762 assert_eq!(settings.workflow_domains, vec![workflow_domain("api.cloudflare.com", &["deploy.yml"], &["production"])]);
763 let refused = |entry: WorkflowDomain| validate(GuardrailSettings { workflow_domains: vec![entry], ..level() }).is_err();
764 assert!(refused(workflow_domain("localhost", &[], &[])));
765 assert!(refused(workflow_domain("api.example.com", &["deploy"], &[])));
766 assert!(refused(workflow_domain("api.example.com", &["de ploy.yml"], &[])));
767 assert!(refused(workflow_domain("api.example.com", &[], &["${{ inputs.env }}"])));
768 let many = (0..=MAX_WORKFLOW_DOMAINS).map(|i| workflow_domain(&format!("h{i}.example.com"), &[], &[])).collect();
769 assert!(validate(GuardrailSettings { workflow_domains: many, ..level() }).is_err());
770 }
771
772 #[test]
773 fn levels_saved_before_workflow_domains_still_read() {
774 let old: GuardrailSettings = serde_json::from_str(r#"{"domains":["example.com"]}"#).unwrap();
775 assert!(old.workflow_domains.is_empty());
776 let old: Guardrails = serde_json::from_value(serde_json::json!({
777 "restrictNetwork": true, "registries": [], "domains": [], "hosts": [], "rules": {}, "deny": [], "budgetUsd": null, "minutes": {}
778 }))
779 .unwrap();
780 assert!(old.workflow_hosts("deploy.yml", Some("production")).is_empty());
781 }
782
783 #[test]
784 fn a_zero_budget_means_no_cap_and_unrestricted_is_kept() {
785 let project = GuardrailSettings {
786 budget_usd: Some(0.0),
787 restrict_network: Some(false),
788 ..level()
789 };
790 let effective = Guardrails::merge(&level(), Some(&project));
791 assert_eq!(effective.budget_usd, None);
792 assert!(!effective.restrict_network);
793 }
794
795 #[test]
796 fn domains_are_tidied_or_refused() {
797 assert_eq!(normalize_domain(" HTTPS://Api.Stripe.com/v1 ").unwrap(), "api.stripe.com");
798 assert_eq!(normalize_domain("*.example.com").unwrap(), "*.example.com");
799 assert_eq!(normalize_domain("example.com:8443").unwrap(), "example.com");
800 assert!(normalize_domain("localhost").is_err());
801 assert!(normalize_domain("*.*.com").is_err());
802 assert!(normalize_domain("exa mple.com").is_err());
803 assert!(normalize_domain("*").is_err());
804 }
805
806 #[test]
807 fn plain_text_patterns_become_shell_rules() {
808 assert_eq!(normalize_pattern("rm -rf").unwrap(), "Bash(rm -rf:*)");
809 assert_eq!(normalize_pattern("Bash(git push --force:*)").unwrap(), "Bash(git push --force:*)");
810 assert_eq!(normalize_pattern("WebFetch").unwrap(), "WebFetch");
811 assert_eq!(normalize_pattern("Read(/etc/**)").unwrap(), "Read(/etc/**)");
812 assert!(normalize_pattern("Bash()").is_err());
813 assert!(normalize_pattern("echo (x").is_err());
814 assert!(normalize_pattern("").is_err());
815 }
816
817 #[test]
818 fn validation_clamps_and_drops_unknowns() {
819 let settings = validate(GuardrailSettings {
820 registries: Some(vec!["npm".into(), "nonsense".into()]),
821 rules: BTreeMap::from([("force_push".to_owned(), false), ("made_up".to_owned(), true)]),
822 domains: vec!["Example.com".into(), "example.com".into(), " ".into()],
823 ..level()
824 })
825 .unwrap();
826 assert_eq!(settings.registries, Some(vec!["npm".to_owned()]));
827 assert_eq!(settings.rules.len(), 1);
828 assert_eq!(settings.domains, vec!["example.com"]);
829 assert!(validate(GuardrailSettings { budget_usd: Some(1000.0), ..level() }).is_err());
830 assert!(validate(GuardrailSettings { budget_usd: Some(f64::NAN), ..level() }).is_err());
831 assert!(
832 validate(GuardrailSettings {
833 minutes: BTreeMap::from([("implement".to_owned(), 0)]),
834 ..level()
835 })
836 .is_err()
837 );
838 assert!(
839 validate(GuardrailSettings {
840 minutes: BTreeMap::from([("lunch".to_owned(), 10)]),
841 ..level()
842 })
843 .is_err()
844 );
845 }
846}