flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/contracts/src/guardrails.rs

859 lines34,580 bytesCodeBlame

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 API1//! 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;
Fast pages, required checks on the branch, self-hosted runners, honest incidents111/// The most workflow-only domains one level keeps.
112pub const MAX_WORKFLOW_DOMAINS: usize = 50;
113const MAX_NAME_CHARS: usize = 255;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API114const 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
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily120/// 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.
123pub 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 API124
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily125/// The cost cap on one run unless the workspace sets another, in US dollars:
126/// [`DEFAULT_RUN_CAP_MICROS`].
127pub 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 API129/// How long each kind of run may take unless the workspace says otherwise.
130pub 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)]
149pub 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 incidents157 /// 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 API161 /// 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 incidents176/// 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)]
181pub 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
191impl 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`.
204fn 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 API209/// 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")]
213pub 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 incidents220 /// 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 API224 /// 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
234impl 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 incidents243 workflow_domains: Vec::new(),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API244 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 incidents273 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 API278 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 incidents324 /// 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 API338 /// 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.
349pub 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:*)`.
381pub 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.
404pub 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 incidents418 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 API431 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 incidents475 workflow_domains,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API476 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 incidents485/// 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.
488pub 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 API515/// A registry as the settings page shows it.
516#[derive(Clone, Debug, Serialize, Deserialize)]
517pub 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)]
525pub 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")]
535pub 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
549impl 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)]
583pub 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)]
594pub 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 quotas604///
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 API609#[derive(Debug, Serialize, Deserialize)]
610pub struct RunGuardrailsArgs {
611 pub repo: RepoPath,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas612 #[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 API614}
615
616/// Why g1t stopped a run by itself.
617#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
618#[serde(rename_all = "snake_case")]
619pub 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 look624 /// 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 API627}
628
629impl 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 look634 Halt::Abuse => "abuse",
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API635 }
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 look639 [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 API640 }
641}
642
643#[cfg(test)]
644mod 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 daily665 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 API672 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 incidents730 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 API796 #[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}