Skip to content

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.sh
678 linesCodeBlameRaw

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 sweep3//!
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 sweep6//!
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 sweep16//!
17//! Each `*Args` struct is the argument of the method of the same name,
18//! served at `POST /rpc/<method>`.
19
20use std::collections::BTreeMap;
21
22use serde::{Deserialize, Serialize};
23
24use crate::agents::RunKind;
25use crate::repos::RepoPath;
26use 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.
30pub const G1T_HOSTS: &[&str] = &["g1t.sh", "api.g1t.sh", "mcp.g1t.sh"];
Fixes from the page sweep31
32/// A package registry, which a project turns on or off as one.
33#[derive(Clone, Copy, Debug)]
34pub 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.
41pub 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 sweep74pub const MAX_DOMAINS: usize = 100;
75/// The most workflow-only domains one level keeps.
76pub const MAX_WORKFLOW_DOMAINS: usize = 50;
77const MAX_NAME_CHARS: usize = 255;
78/// The longest any run may be allowed to take, in minutes.
79pub 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 sweep83pub const DEFAULT_RUN_CAP_MICROS: i64 = 2_000_000;
84
85/// How long each kind of run may take unless the workspace says otherwise.
86pub 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 sweep103#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase", default)]
105pub 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)]
130pub 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
140impl 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`.
153fn 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")]
162pub 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
177impl 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 sweep180 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.
276pub 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.
306pub 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.
364pub 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)]
393pub 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")]
403pub 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
416impl 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)]
442pub 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)]
453pub 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)]
469pub 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")]
478pub 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
488impl 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)]
503mod 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 sweep519 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 sweep558 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 sweep633 }
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 sweep638 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.