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