pr_01m47d15m3e54sn21z27rpy5n9/crates/contracts/src/guardrails.rs

665 lines24,702 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;
111const MAX_PATTERN_CHARS: usize = 200;
112/// The most a run may be allowed to cost, in US dollars.
113pub const MAX_BUDGET_USD: f64 = 100.0;
114/// The longest any run may be allowed to take, in minutes.
115pub const MAX_MINUTES: u32 = 240;
116
117/// The cost cap on one run unless the workspace sets another, in US dollars.
118pub const DEFAULT_BUDGET_USD: f64 = 5.0;
119
120/// How long each kind of run may take unless the workspace says otherwise.
121pub fn default_minutes(kind: RunKind) -> u32 {
122 match kind {
123 RunKind::Implement => 90,
124 RunKind::Revise => 60,
125 RunKind::Review => 30,
126 RunKind::Answer => 20,
127 RunKind::Update => 45,
128 RunKind::Plan => 30,
129 RunKind::Checks => 45,
130 RunKind::Queue => 45,
131 RunKind::Mergecheck => 10,
132 }
133}
134
135/// What one level, the workspace or a project, sets. Anything left unset
136/// is inherited: a project from its workspace, a workspace from g1t's
137/// defaults. Domains and deny patterns add up across the two levels.
138#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
139#[serde(rename_all = "camelCase", default)]
140pub struct GuardrailSettings {
141 /// Whether sandboxes may reach only the allowed hosts.
142 pub restrict_network: Option<bool>,
143 /// The registries that are on, by id. Replaces the inherited list.
144 pub registries: Option<Vec<String>>,
145 /// More hosts to allow: `example.com`, or `*.example.com` for its
146 /// subdomains.
147 pub domains: Vec<String>,
148 /// Built-in command rules turned on or off, by id.
149 pub rules: BTreeMap<String, bool>,
150 /// Commands and tools to refuse, as permission rules:
151 /// `Bash(terraform apply:*)`, `Read(/etc/**)`, `WebFetch`.
152 pub deny: Vec<String>,
153 /// The most a run may cost, in US dollars. Zero means no cap.
154 pub budget_usd: Option<f64>,
155 /// How long a run may take, in minutes, by kind of run.
156 pub minutes: BTreeMap<String, u32>,
157 /// Username of whoever last changed this level.
158 pub updated_by: Option<String>,
159 /// RFC 3339.
160 pub updated_at: Option<String>,
161}
162
163/// The guardrails a run actually gets: g1t's defaults, then the
164/// workspace's, then the project's.
165#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
166#[serde(rename_all = "camelCase")]
167pub struct Guardrails {
168 pub restrict_network: bool,
169 pub registries: Vec<String>,
170 /// The domains listed at either level, workspace first.
171 pub domains: Vec<String>,
172 /// Every host a sandbox may reach: g1t's, the registries', the domains.
173 pub hosts: Vec<String>,
174 /// Every built-in rule, on or off.
175 pub rules: BTreeMap<String, bool>,
176 /// The deny patterns of both levels, workspace first.
177 pub deny: Vec<String>,
178 /// None: no cap.
179 pub budget_usd: Option<f64>,
180 /// Every kind of run.
181 pub minutes: BTreeMap<String, u32>,
182}
183
184impl Guardrails {
185 /// g1t's defaults: network restricted to g1t and every registry, every
186 /// command rule on, a cost cap and a time cap for each kind of run.
187 pub fn defaults() -> Self {
188 let mut defaults = Guardrails {
189 restrict_network: true,
190 registries: REGISTRIES.iter().map(|registry| registry.id.to_owned()).collect(),
191 domains: Vec::new(),
192 hosts: Vec::new(),
193 rules: COMMAND_RULES.iter().map(|rule| (rule.id.to_owned(), true)).collect(),
194 deny: Vec::new(),
195 budget_usd: Some(DEFAULT_BUDGET_USD),
196 minutes: RunKind::ALL
197 .into_iter()
198 .map(|kind| (kind.as_str().to_owned(), default_minutes(kind)))
199 .collect(),
200 };
201 defaults.hosts = defaults.allowed_hosts();
202 defaults
203 }
204
205 /// One level laid over what it inherits.
206 pub fn apply(mut self, level: &GuardrailSettings) -> Self {
207 if let Some(restrict) = level.restrict_network {
208 self.restrict_network = restrict;
209 }
210 if let Some(registries) = &level.registries {
211 self.registries = REGISTRIES
212 .iter()
213 .filter(|registry| registries.iter().any(|id| id == registry.id))
214 .map(|registry| registry.id.to_owned())
215 .collect();
216 }
217 for domain in &level.domains {
218 if !self.domains.contains(domain) {
219 self.domains.push(domain.clone());
220 }
221 }
222 for (id, on) in &level.rules {
223 if let Some(rule) = self.rules.get_mut(id) {
224 *rule = *on;
225 }
226 }
227 for pattern in &level.deny {
228 if !self.deny.contains(pattern) {
229 self.deny.push(pattern.clone());
230 }
231 }
232 if let Some(budget) = level.budget_usd {
233 self.budget_usd = (budget > 0.0).then_some(budget);
234 }
235 for (kind, minutes) in &level.minutes {
236 if let Some(cap) = self.minutes.get_mut(kind) {
237 *cap = *minutes;
238 }
239 }
240 self.hosts = self.allowed_hosts();
241 self
242 }
243
244 /// The workspace's defaults with a project's overrides on top.
245 pub fn merge(workspace: &GuardrailSettings, project: Option<&GuardrailSettings>) -> Self {
246 let inherited = Guardrails::defaults().apply(workspace);
247 match project {
248 Some(project) => inherited.apply(project),
249 None => inherited,
250 }
251 }
252
253 fn allowed_hosts(&self) -> Vec<String> {
254 let mut hosts: Vec<String> = G1T_HOSTS.iter().map(|host| (*host).to_owned()).collect();
255 for registry in REGISTRIES {
256 if self.registries.iter().any(|id| id == registry.id) {
257 hosts.extend(registry.hosts.iter().map(|host| (*host).to_owned()));
258 }
259 }
260 for domain in &self.domains {
261 if !hosts.contains(domain) {
262 hosts.push(domain.clone());
263 }
264 }
265 hosts
266 }
267
268 /// The time cap of a kind of run, in minutes.
269 pub fn minutes_for(&self, kind: RunKind) -> u32 {
270 self.minutes
271 .get(kind.as_str())
272 .copied()
273 .unwrap_or_else(|| default_minutes(kind))
274 }
275}
276
277/// A domain as it is kept: lower case, no scheme, path or port, optionally
278/// `*.` for its subdomains. Refused if it is not a host name.
279pub fn normalize_domain(input: &str) -> Result<String, String> {
280 let mut domain = input.trim().to_lowercase();
281 for scheme in ["https://", "http://"] {
282 if let Some(rest) = domain.strip_prefix(scheme) {
283 domain = rest.to_owned();
284 }
285 }
286 if let Some(at) = domain.find(['/', ':']) {
287 domain.truncate(at);
288 }
289 let domain = domain.trim_end_matches('.').to_owned();
290 let bare = domain.strip_prefix("*.").unwrap_or(&domain);
291 let labels: Vec<&str> = bare.split('.').collect();
292 let valid = labels.len() >= 2
293 && bare.len() <= 253
294 && labels.iter().all(|label| {
295 !label.is_empty()
296 && label.len() <= 63
297 && !label.starts_with('-')
298 && !label.ends_with('-')
299 && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
300 });
301 if valid {
302 Ok(domain)
303 } else {
304 Err(format!("{} is not a domain. Use a host name such as example.com, or *.example.com for its subdomains.", input.trim()))
305 }
306}
307
308/// A deny pattern as it is kept: a permission rule such as
309/// `Bash(terraform apply:*)`. Plain text is taken as the start of a shell
310/// command: `rm -rf` becomes `Bash(rm -rf:*)`.
311pub fn normalize_pattern(input: &str) -> Result<String, String> {
312 let pattern = input.trim();
313 if pattern.is_empty() || pattern.chars().count() > MAX_PATTERN_CHARS || pattern.contains('\n') {
314 return Err(format!("A deny pattern is one line of at most {MAX_PATTERN_CHARS} characters."));
315 }
316 let tool_end = pattern.find('(').unwrap_or(pattern.len());
317 let tool = &pattern[..tool_end];
318 let is_rule = !tool.is_empty()
319 && tool.chars().next().is_some_and(|c| c.is_ascii_uppercase())
320 && tool.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
321 && (tool_end == pattern.len() || (pattern.ends_with(')') && pattern.len() > tool_end + 2));
322 if is_rule {
323 return Ok(pattern.to_owned());
324 }
325 if pattern.contains(['(', ')']) {
326 return Err(format!(
327 "{pattern} is not a rule. Write a tool and what to refuse, such as Bash(terraform apply:*), or just the start of a command."
328 ));
329 }
330 Ok(format!("Bash({pattern}:*)"))
331}
332
333/// One level's settings, checked and tidied before they are kept.
334pub fn validate(settings: GuardrailSettings) -> Result<GuardrailSettings, String> {
335 let mut domains = Vec::new();
336 for domain in &settings.domains {
337 if domain.trim().is_empty() {
338 continue;
339 }
340 let domain = normalize_domain(domain)?;
341 if !domains.contains(&domain) {
342 domains.push(domain);
343 }
344 }
345 if domains.len() > MAX_DOMAINS {
346 return Err(format!("At most {MAX_DOMAINS} domains can be listed."));
347 }
348 let mut deny = Vec::new();
349 for pattern in &settings.deny {
350 if pattern.trim().is_empty() {
351 continue;
352 }
353 let pattern = normalize_pattern(pattern)?;
354 if !deny.contains(&pattern) {
355 deny.push(pattern);
356 }
357 }
358 if deny.len() > MAX_PATTERNS {
359 return Err(format!("At most {MAX_PATTERNS} deny patterns can be listed."));
360 }
361 if let Some(budget) = settings.budget_usd
362 && (!budget.is_finite() || !(0.0..=MAX_BUDGET_USD).contains(&budget))
363 {
364 return Err(format!("A run's cost cap is between $0 (no cap) and ${MAX_BUDGET_USD:.0}."));
365 }
366 let mut minutes = BTreeMap::new();
367 for (kind, cap) in settings.minutes {
368 if RunKind::parse(&kind).is_none() {
369 return Err(format!("{kind} is not a kind of run."));
370 }
371 if !(1..=MAX_MINUTES).contains(&cap) {
372 return Err(format!("A run's time cap is between 1 and {MAX_MINUTES} minutes."));
373 }
374 minutes.insert(kind, cap);
375 }
376 let rules = settings
377 .rules
378 .into_iter()
379 .filter(|(id, _)| COMMAND_RULES.iter().any(|rule| rule.id == id))
380 .collect();
381 let registries = settings.registries.map(|ids| {
382 REGISTRIES
383 .iter()
384 .filter(|registry| ids.iter().any(|id| id == registry.id))
385 .map(|registry| registry.id.to_owned())
386 .collect()
387 });
388 Ok(GuardrailSettings {
389 restrict_network: settings.restrict_network,
390 registries,
391 domains,
392 rules,
393 deny,
394 budget_usd: settings.budget_usd,
395 minutes,
396 updated_by: settings.updated_by,
397 updated_at: settings.updated_at,
398 })
399}
400
401/// A registry as the settings page shows it.
402#[derive(Clone, Debug, Serialize, Deserialize)]
403pub struct RegistryInfo {
404 pub id: String,
405 pub name: String,
406 pub hosts: Vec<String>,
407}
408
409/// A command rule as the settings page shows it.
410#[derive(Clone, Debug, Serialize, Deserialize)]
411pub struct RuleInfo {
412 pub id: String,
413 pub title: String,
414 pub about: String,
415}
416
417/// Everything the settings pages show: each level as it was set, what
418/// each inherits, and what is in force.
419#[derive(Clone, Debug, Serialize, Deserialize)]
420#[serde(rename_all = "camelCase")]
421pub struct GuardrailsView {
422 pub workspace: GuardrailSettings,
423 /// None when no project was asked about.
424 pub project: Option<GuardrailSettings>,
425 pub defaults: Guardrails,
426 /// g1t's defaults with the workspace's: what a project inherits.
427 pub inherited: Guardrails,
428 /// What runs get: the project's, or with no project, the workspace's.
429 pub effective: Guardrails,
430 pub g1t_hosts: Vec<String>,
431 pub registries: Vec<RegistryInfo>,
432 pub rules: Vec<RuleInfo>,
433}
434
435impl GuardrailsView {
436 pub fn new(workspace: GuardrailSettings, project: Option<GuardrailSettings>) -> Self {
437 let inherited = Guardrails::merge(&workspace, None);
438 let effective = Guardrails::merge(&workspace, project.as_ref());
439 GuardrailsView {
440 workspace,
441 project,
442 defaults: Guardrails::defaults(),
443 inherited,
444 effective,
445 g1t_hosts: G1T_HOSTS.iter().map(|host| (*host).to_owned()).collect(),
446 registries: REGISTRIES
447 .iter()
448 .map(|registry| RegistryInfo {
449 id: registry.id.to_owned(),
450 name: registry.name.to_owned(),
451 hosts: registry.hosts.iter().map(|host| (*host).to_owned()).collect(),
452 })
453 .collect(),
454 rules: COMMAND_RULES
455 .iter()
456 .map(|rule| RuleInfo {
457 id: rule.id.to_owned(),
458 title: rule.title.to_owned(),
459 about: rule.about.to_owned(),
460 })
461 .collect(),
462 }
463 }
464}
465
466/// `get_guardrails`: a workspace's guardrails, and with `repo`, that
467/// project's too. Members only. Returns `Outcome<GuardrailsView>`.
468#[derive(Debug, Serialize, Deserialize)]
469pub struct GetGuardrailsArgs {
470 pub viewer: Viewer,
471 pub workspace: String,
472 #[serde(default)]
473 pub repo: Option<RepoPath>,
474}
475
476/// `update_guardrails`: replaces one level's settings: the workspace's
477/// (owners only) or, with `repo`, that project's (members). Returns
478/// `Outcome<GuardrailsView>`.
479#[derive(Debug, Serialize, Deserialize)]
480pub struct UpdateGuardrailsArgs {
481 pub actor: User,
482 pub workspace: String,
483 #[serde(default)]
484 pub repo: Option<RepoPath>,
485 pub settings: GuardrailSettings,
486}
487
488/// `run_guardrails`: what a run in `repo` gets. For the runner service,
489/// which is trusted. Returns `Outcome<Guardrails>`.
490#[derive(Debug, Serialize, Deserialize)]
491pub struct RunGuardrailsArgs {
492 pub repo: RepoPath,
493}
494
495/// Why g1t stopped a run by itself.
496#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
497#[serde(rename_all = "snake_case")]
498pub enum Halt {
499 /// It reached its cost cap.
500 Budget,
501 /// It reached its time cap.
502 Time,
503 /// Its sandbox looked like it was mining cryptocurrency: CPU pinned for
504 /// a long time with little I/O and no progress. Held for review.
505 Abuse,
506}
507
508impl Halt {
509 pub fn as_str(self) -> &'static str {
510 match self {
511 Halt::Budget => "budget",
512 Halt::Time => "time",
513 Halt::Abuse => "abuse",
514 }
515 }
516
517 pub fn parse(value: &str) -> Option<Halt> {
518 [Halt::Budget, Halt::Time, Halt::Abuse].into_iter().find(|halt| halt.as_str() == value)
519 }
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 fn level() -> GuardrailSettings {
527 GuardrailSettings::default()
528 }
529
530 #[test]
531 fn defaults_restrict_to_g1t_and_every_registry() {
532 let defaults = Guardrails::defaults();
533 assert!(defaults.restrict_network);
534 assert!(defaults.hosts.iter().any(|host| host == "api.g1t.sh"));
535 assert!(defaults.hosts.iter().any(|host| host == "registry.npmjs.org"));
536 assert!(defaults.hosts.iter().any(|host| host == "codeload.github.com"));
537 assert!(!defaults.hosts.iter().any(|host| host == "github.com"));
538 assert!(defaults.rules.values().all(|on| *on));
539 assert_eq!(defaults.budget_usd, Some(DEFAULT_BUDGET_USD));
540 assert_eq!(defaults.minutes_for(RunKind::Implement), 90);
541 }
542
543 #[test]
544 fn nothing_set_inherits_everything() {
545 assert_eq!(Guardrails::merge(&level(), Some(&level())), Guardrails::defaults());
546 }
547
548 #[test]
549 fn a_project_overrides_its_workspace() {
550 let workspace = GuardrailSettings {
551 registries: Some(vec!["npm".into(), "pypi".into()]),
552 budget_usd: Some(2.0),
553 rules: BTreeMap::from([("sudo".to_owned(), false)]),
554 minutes: BTreeMap::from([("implement".to_owned(), 30)]),
555 ..level()
556 };
557 let project = GuardrailSettings {
558 registries: Some(vec!["crates".into()]),
559 budget_usd: Some(8.0),
560 rules: BTreeMap::from([("sudo".to_owned(), true), ("print_env".to_owned(), false)]),
561 ..level()
562 };
563 let inherited = Guardrails::merge(&workspace, None);
564 assert_eq!(inherited.registries, vec!["npm", "pypi"]);
565 assert_eq!(inherited.budget_usd, Some(2.0));
566 assert!(!inherited.rules["sudo"]);
567 assert!(inherited.hosts.iter().any(|host| host == "pypi.org"));
568 assert!(!inherited.hosts.iter().any(|host| host == "crates.io"));
569
570 let effective = Guardrails::merge(&workspace, Some(&project));
571 assert_eq!(effective.registries, vec!["crates"]);
572 assert!(effective.hosts.iter().any(|host| host == "crates.io"));
573 assert!(!effective.hosts.iter().any(|host| host == "pypi.org"));
574 assert_eq!(effective.budget_usd, Some(8.0));
575 assert!(effective.rules["sudo"]);
576 assert!(!effective.rules["print_env"]);
577 // Inherited where the project says nothing.
578 assert_eq!(effective.minutes_for(RunKind::Implement), 30);
579 assert_eq!(effective.minutes_for(RunKind::Review), 30);
580 // g1t's own hosts can never be turned off.
581 assert!(effective.hosts.iter().any(|host| host == "g1t.sh"));
582 }
583
584 #[test]
585 fn domains_and_deny_patterns_add_up() {
586 let workspace = GuardrailSettings {
587 domains: vec!["api.stripe.com".into()],
588 deny: vec!["Bash(terraform apply:*)".into()],
589 ..level()
590 };
591 let project = GuardrailSettings {
592 domains: vec!["*.example.com".into(), "api.stripe.com".into()],
593 deny: vec!["Bash(kubectl:*)".into()],
594 ..level()
595 };
596 let effective = Guardrails::merge(&workspace, Some(&project));
597 assert_eq!(effective.domains, vec!["api.stripe.com", "*.example.com"]);
598 assert_eq!(effective.deny, vec!["Bash(terraform apply:*)", "Bash(kubectl:*)"]);
599 assert!(effective.hosts.iter().any(|host| host == "*.example.com"));
600 }
601
602 #[test]
603 fn a_zero_budget_means_no_cap_and_unrestricted_is_kept() {
604 let project = GuardrailSettings {
605 budget_usd: Some(0.0),
606 restrict_network: Some(false),
607 ..level()
608 };
609 let effective = Guardrails::merge(&level(), Some(&project));
610 assert_eq!(effective.budget_usd, None);
611 assert!(!effective.restrict_network);
612 }
613
614 #[test]
615 fn domains_are_tidied_or_refused() {
616 assert_eq!(normalize_domain(" HTTPS://Api.Stripe.com/v1 ").unwrap(), "api.stripe.com");
617 assert_eq!(normalize_domain("*.example.com").unwrap(), "*.example.com");
618 assert_eq!(normalize_domain("example.com:8443").unwrap(), "example.com");
619 assert!(normalize_domain("localhost").is_err());
620 assert!(normalize_domain("*.*.com").is_err());
621 assert!(normalize_domain("exa mple.com").is_err());
622 assert!(normalize_domain("*").is_err());
623 }
624
625 #[test]
626 fn plain_text_patterns_become_shell_rules() {
627 assert_eq!(normalize_pattern("rm -rf").unwrap(), "Bash(rm -rf:*)");
628 assert_eq!(normalize_pattern("Bash(git push --force:*)").unwrap(), "Bash(git push --force:*)");
629 assert_eq!(normalize_pattern("WebFetch").unwrap(), "WebFetch");
630 assert_eq!(normalize_pattern("Read(/etc/**)").unwrap(), "Read(/etc/**)");
631 assert!(normalize_pattern("Bash()").is_err());
632 assert!(normalize_pattern("echo (x").is_err());
633 assert!(normalize_pattern("").is_err());
634 }
635
636 #[test]
637 fn validation_clamps_and_drops_unknowns() {
638 let settings = validate(GuardrailSettings {
639 registries: Some(vec!["npm".into(), "nonsense".into()]),
640 rules: BTreeMap::from([("force_push".to_owned(), false), ("made_up".to_owned(), true)]),
641 domains: vec!["Example.com".into(), "example.com".into(), " ".into()],
642 ..level()
643 })
644 .unwrap();
645 assert_eq!(settings.registries, Some(vec!["npm".to_owned()]));
646 assert_eq!(settings.rules.len(), 1);
647 assert_eq!(settings.domains, vec!["example.com"]);
648 assert!(validate(GuardrailSettings { budget_usd: Some(1000.0), ..level() }).is_err());
649 assert!(validate(GuardrailSettings { budget_usd: Some(f64::NAN), ..level() }).is_err());
650 assert!(
651 validate(GuardrailSettings {
652 minutes: BTreeMap::from([("implement".to_owned(), 0)]),
653 ..level()
654 })
655 .is_err()
656 );
657 assert!(
658 validate(GuardrailSettings {
659 minutes: BTreeMap::from([("lunch".to_owned(), 10)]),
660 ..level()
661 })
662 .is_err()
663 );
664 }
665}