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