Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily
- Artifacts: every interaction metered by type with the billable mapping as data, hour-long credentials, PR forks retired a week after close, streamed pushes with size limits, retries and a breaker, a Git storage status part, refs-version caches, coalesced mergeability. - Speed: read methods no longer look like writes, so public pages cache and replicas serve; the pull request page reads in one batch; the overview streams. - Security: likely test values never block or count as critical; dismiss and reopen with reasons and an activity log; security updates opened by g1t itself as pull requests; large pushes scanned after they land. - A g1t system identity for platform work. - Costs: Cloudflare's bill ingested daily and reconciled with our meters and revenue; a versioned price book with proposals; margin alerts; free workspaces never billed for scans or embeddings. - Truth audit: about 95 claims corrected across pricing, policies, docs and llms.txt; hosted models only for listed workspaces while payments are in test mode; push-to-create is private; model tokens revoked when a run ends; update_workspace.
| 1007 | 1007 | "base64 0.22.1", | |
| 1008 | 1008 | "ed25519-dalek", | |
| 1009 | 1009 | "g1t-actions", | |
| 1010 | + | "g1t-scan", | |
| 1010 | 1011 | "hex", | |
| 1011 | 1012 | "serde", | |
| 1012 | 1013 | "serde_json", | |
| 1062 | 1063 | "getrandom 0.2.17", | |
| 1063 | 1064 | "serde", | |
| 1064 | 1065 | "serde_json", | |
| 1066 | + | "serde_yaml", | |
| 1065 | 1067 | "worker", | |
| 1066 | 1068 | ] | |
| 1067 | 1069 |
| 22 | 22 | - **Secure and healthy.** Push protection, history scanning, dependency | |
| 23 | 23 | upkeep that an agent lands, and an audit log on every workspace. | |
| 24 | 24 | - **Open and fair.** MIT licensed and self-hostable (an early Docker Compose | |
| 25 | − | version of the core forge). The forge is free; compute is what it costs | |
| 25 | + | version of the core forge, in `deploy/self-host`). The forge is free; compute is what it costs | |
| 26 | 26 | plus 20%, never per seat. | |
| 27 | 27 | ||
| 28 | 28 | g1t is made by Flagon, Inc. It is also an entry in Cloudflare's **Build the | |
| 69 | 69 | verdicts, from people and from agents. | |
| 70 | 70 | - Overlap: each pull request shows which others in progress change the | |
| 71 | 71 | same files, while the work is still going on. | |
| 72 | − | - Catch-up: when `main` has moved under a pull request, a g1t agent merges | |
| 73 | − | it in and resolves any conflict. | |
| 72 | + | - Catch-up: when `main` has moved under a pull request, g1t merges it in, | |
| 73 | + | and a g1t agent resolves any conflict. | |
| 74 | 74 | - Reviews written by a g1t agent, on request: line comments, a summary and | |
| 75 | 75 | a verdict. | |
| 76 | − | - Importing a public repository from GitHub or any git host. | |
| 76 | + | - Importing a public repository from any git host by its address, and | |
| 77 | + | public or private repositories through g1t's GitHub App, imported once, | |
| 78 | + | mirrored, or pushed back to GitHub. | |
| 77 | 79 | - Merging: lands a pull request on `main`, closes its issue naming the pull | |
| 78 | 80 | request that resolved it, and closes the others for that issue as | |
| 79 | − | superseded. Refused when the pull request is behind, so no commit is lost. | |
| 81 | + | superseded. When `main` has moved, the pull request is brought up to date | |
| 82 | + | first, or refused where the repository requires that, so no commit is | |
| 83 | + | lost. | |
| 80 | 84 | - g1t agents: g1t's own agents working on an issue in sandboxes on | |
| 81 | 85 | Cloudflare Containers, seeing each pull request through checks, an | |
| 82 | 86 | agent's review, revisions and catch-up. | |
| 101 | 105 | - An event bus: every state change is published, logged and delivered to | |
| 102 | 106 | subscribers. | |
| 103 | 107 | ||
| 104 | − | Not built yet: a code-search index and the Soon pages in each project's | |
| 105 | − | menu. Git over SSH waits on inbound TCP on port 22, which on Cloudflare | |
| 108 | + | Not built yet: what the Soon pages in each project's menu describe. Git | |
| 109 | + | over SSH waits on inbound TCP on port 22, which on Cloudflare | |
| 106 | 110 | means Workers inbound TCP, a beta g1t has applied for and is waiting on. | |
| 107 | 111 | Use HTTPS until then. See the build order in the plan. | |
| 108 | 112 | ||
| 131 | 135 | | `services/repos` | Repository registry, contents, forks, diffs, landing, git over HTTPS. Rust. | | |
| 132 | 136 | | `services/work` | Issues, pull requests, reviews, check runs and sessions. Rust. | | |
| 133 | 137 | | `services/events` | The event bus and its log. Rust. | | |
| 134 | − | | `services/runner` | Starts sandboxes: for g1t agents, workflow jobs and the merge queue. | | |
| 135 | − | | `services/og` | Social cards at `og.g1t.sh`: a PNG per page, showing only what anyone may see. | | |
| 138 | + | | `services/search` | Site-wide search and Explore. Rust. | | |
| 139 | + | | `services/billing` | Usage, the price book, limits, invoices and payments. Rust. | | |
| 140 | + | | `services/actions` | GitHub Actions workflows, runs, caches and self-hosted runners. Rust. | | |
| 141 | + | | `services/security` | Push protection findings, history scanning and dependency upkeep. Rust. | | |
| 142 | + | | `services/integrations` | Model providers, alerts, trackers and the GitHub App. Rust. | | |
| 143 | + | | `services/webhooks` | Webhook deliveries. Rust. | | |
| 144 | + | | `services/runner` | Starts sandboxes: for g1t agents, workflow jobs and the merge queue. TypeScript. | | |
| 145 | + | | `services/projects` | Projects and the dependencies between them. TypeScript. | | |
| 146 | + | | `services/deployments` | Builds, previews and production on `g1t.page`. TypeScript. | | |
| 147 | + | | `services/pages` | Serves every app deployed on `g1t.page`, and custom domains. TypeScript. | | |
| 148 | + | | `services/models` | The model proxy at `models.g1t.sh`. TypeScript. | | |
| 149 | + | | `services/context` | The context hub: catalog, search and scorecards. TypeScript. | | |
| 150 | + | | `services/og` | Social cards at `og.g1t.sh`: a PNG per page, showing only what anyone may see. TypeScript. | | |
| 151 | + | | `apps/status` | `status.g1t.sh`. TypeScript. | | |
| 152 | + | | `apps/sudo` | g1t's own staff console. | | |
| 136 | 153 | | `crates/runner` | The program inside a sandbox: runs an agent, a workflow job or a merge queue build, and reports back. Rust. | | |
| 137 | 154 | | `crates/contracts` | Types and service interfaces for the Rust services. | | |
| 138 | 155 | | `crates/kit` | Plumbing shared by Rust services on Workers. | | |
| 156 | + | | `crates/actions` | Reads workflows and evaluates their expressions. Rust. | | |
| 157 | + | | `crates/scan` | Secret and lockfile scanning, shared by services. Rust. | | |
| 158 | + | | `crates/secrets` | Secrets at rest and signatures. Rust. | | |
| 139 | 159 | | `crates/sshd` | Git over SSH, bridged to Artifacts. Not deployed yet. | | |
| 140 | 160 | | `packages/contracts` | The same interfaces for TypeScript callers. | | |
| 141 | 161 | | `packages/theme` | Design tokens and the logo, shared by the site and the docs. | | |
| 162 | + | | `deploy` | `stack.jsonc`, every deployable part and its resources; `self-host`, the Docker Compose version. | | |
| 142 | 163 | ||
| 143 | − | Each service is its own Worker with its own database. They call each other | |
| 144 | − | through service bindings and react to each other through events. Everything | |
| 145 | − | that is not a web UI is written in Rust, except the small Worker that | |
| 146 | − | starts sandboxes, which uses a TypeScript-only Cloudflare library. | |
| 164 | + | Each service is its own Worker, and each one that keeps data has its own | |
| 165 | + | database. They call each other through service bindings and react to each | |
| 166 | + | other through events. The core services (accounts, repositories, work, | |
| 167 | + | events, billing, Actions, security and the API) are written in Rust; the | |
| 168 | + | rest are the web apps and the Workers marked TypeScript above. | |
| 147 | 169 | ||
| 148 | 170 | ## Run your own | |
| 149 | 171 | ||
| 150 | 172 | You need a Cloudflare account on the Workers Paid plan (Artifacts requires | |
| 151 | − | it), Node 22 or newer, Rust with the `wasm32-unknown-unknown` target, and | |
| 173 | + | it), Node 22.22 or newer (`engines` in `package.json`; g1t is built on | |
| 174 | + | Node 24), Rust with the `wasm32-unknown-unknown` target, and | |
| 152 | 175 | Docker to build the sandbox image. | |
| 153 | 176 | ||
| 154 | 177 | ```sh | |
| 158 | 181 | ||
| 159 | 182 | Then, once: | |
| 160 | 183 | ||
| 161 | − | 1. Create each service's D1 database and the event queues with | |
| 162 | − | `npx wrangler d1 create <name>` and `npx wrangler queues create <name>` | |
| 163 | − | (the names are in each `wrangler.jsonc`). | |
| 184 | + | 1. Create the resources each part needs: D1 databases, queues, KV | |
| 185 | + | namespaces, R2 buckets and the Artifacts namespace (`npx wrangler d1 | |
| 186 | + | create <name>`, `npx wrangler queues create <name>`, and so on), and set | |
| 187 | + | each part's secrets. `deploy/stack.jsonc` lists them all. | |
| 164 | 188 | 2. Put your own `account_id`, database ids and hostnames in each | |
| 165 | 189 | `wrangler.jsonc`. | |
| 166 | 190 | 3. For [Deployments](https://docs.g1t.sh/guides/deployments/), which needs |
| 1 | + | //! Security alerts as the API gives them: a secret found in a repository, | |
| 2 | + | //! or a dependency with a known vulnerability, in one flat `snake_case` | |
| 3 | + | //! shape with `kind` saying which. | |
| 4 | + | //! | |
| 5 | + | //! The security service keeps them as `SecretFinding` and `Vulnerability` | |
| 6 | + | //! (see `g1t_contracts::security`); this is the public form of both. | |
| 7 | + | ||
| 8 | + | use g1t_contracts::security::{ | |
| 9 | + | AlertChange, AlertState, DismissReason, SecretFinding, SecretStatus, SecurityUpdate, UpdateState, | |
| 10 | + | Vulnerability, | |
| 11 | + | }; | |
| 12 | + | use serde::{Deserialize, Serialize}; | |
| 13 | + | ||
| 14 | + | /// Which kind of alert. | |
| 15 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 16 | + | #[serde(rename_all = "snake_case")] | |
| 17 | + | pub enum AlertKind { | |
| 18 | + | Secret, | |
| 19 | + | Dependency, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | impl AlertKind { | |
| 23 | + | pub const ALL: [AlertKind; 2] = [AlertKind::Secret, AlertKind::Dependency]; | |
| 24 | + | ||
| 25 | + | pub fn as_str(self) -> &'static str { | |
| 26 | + | match self { | |
| 27 | + | AlertKind::Secret => "secret", | |
| 28 | + | AlertKind::Dependency => "dependency", | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | pub fn parse(text: &str) -> Option<AlertKind> { | |
| 33 | + | AlertKind::ALL.into_iter().find(|kind| kind.as_str() == text) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// The kind an alert's id names: `sec_…` or `vul_…`. | |
| 37 | + | pub fn of_id(id: &str) -> Option<AlertKind> { | |
| 38 | + | if id.starts_with("sec_") { | |
| 39 | + | Some(AlertKind::Secret) | |
| 40 | + | } else if id.starts_with("vul_") { | |
| 41 | + | Some(AlertKind::Dependency) | |
| 42 | + | } else { | |
| 43 | + | None | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// Whether `reason` can dismiss an alert of this kind. | |
| 48 | + | pub fn takes(self, reason: DismissReason) -> bool { | |
| 49 | + | reason.for_secrets() == (self == AlertKind::Secret) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /// The reasons that dismiss an alert of this kind, as words. | |
| 53 | + | pub fn reasons(self) -> Vec<&'static str> { | |
| 54 | + | DismissReason::ALL | |
| 55 | + | .into_iter() | |
| 56 | + | .filter(|reason| self.takes(*reason)) | |
| 57 | + | .map(DismissReason::as_str) | |
| 58 | + | .collect() | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// One alert. | |
| 63 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 64 | + | pub struct SecurityAlert { | |
| 65 | + | pub kind: AlertKind, | |
| 66 | + | /// `sec_…` for a secret, `vul_…` for a dependency. | |
| 67 | + | pub id: String, | |
| 68 | + | pub state: AlertState, | |
| 69 | + | #[serde(flatten)] | |
| 70 | + | pub detail: AlertDetail, | |
| 71 | + | /// RFC 3339. | |
| 72 | + | pub found_at: String, | |
| 73 | + | /// Why it was dismissed (or, for a secret, revoked); null while open. | |
| 74 | + | pub dismissed_reason: Option<DismissReason>, | |
| 75 | + | pub dismissed_comment: Option<String>, | |
| 76 | + | /// Who dismissed it. | |
| 77 | + | pub dismissed_by: Option<String>, | |
| 78 | + | /// RFC 3339. | |
| 79 | + | pub dismissed_at: Option<String>, | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// What only one kind of alert has. | |
| 83 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 84 | + | #[serde(untagged)] | |
| 85 | + | pub enum AlertDetail { | |
| 86 | + | Secret(SecretDetail), | |
| 87 | + | Dependency(DependencyDetail), | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 91 | + | pub struct SecretDetail { | |
| 92 | + | /// `aws_access_key`, `github_token`, … | |
| 93 | + | pub secret_type: String, | |
| 94 | + | /// "an AWS access key". | |
| 95 | + | pub label: String, | |
| 96 | + | pub path: String, | |
| 97 | + | pub line: u32, | |
| 98 | + | pub commit: String, | |
| 99 | + | /// Enough of it to recognise; the secret itself is never kept. | |
| 100 | + | pub preview: String, | |
| 101 | + | /// open, blocked, allowed or resolved. | |
| 102 | + | pub status: SecretStatus, | |
| 103 | + | /// `push` or `history`. | |
| 104 | + | pub source: String, | |
| 105 | + | /// Why the value looks made for tests or documentation, when it does. | |
| 106 | + | pub test_value: Option<String>, | |
| 107 | + | /// Who pushed it, for a push. | |
| 108 | + | pub found_by: Option<String>, | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 112 | + | pub struct DependencyDetail { | |
| 113 | + | pub ecosystem: String, | |
| 114 | + | pub package: String, | |
| 115 | + | pub version: String, | |
| 116 | + | /// The lockfile that resolves it. | |
| 117 | + | pub manifest: String, | |
| 118 | + | pub advisory: String, | |
| 119 | + | pub osv_id: String, | |
| 120 | + | pub summary: String, | |
| 121 | + | pub severity: String, | |
| 122 | + | /// Null when no patched version is available. | |
| 123 | + | pub fixed_version: Option<String>, | |
| 124 | + | pub fixed_at: Option<String>, | |
| 125 | + | /// The pull request g1t opens to upgrade it, once started. | |
| 126 | + | pub update: Option<AlertUpdate>, | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | /// The security update for a dependency's package. | |
| 130 | + | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 131 | + | pub struct AlertUpdate { | |
| 132 | + | pub state: UpdateState, | |
| 133 | + | pub target: String, | |
| 134 | + | pub branch: Option<String>, | |
| 135 | + | pub pull: Option<u32>, | |
| 136 | + | pub issue: Option<u32>, | |
| 137 | + | pub error: Option<String>, | |
| 138 | + | pub updated_at: String, | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | impl From<SecurityUpdate> for AlertUpdate { | |
| 142 | + | fn from(update: SecurityUpdate) -> Self { | |
| 143 | + | AlertUpdate { | |
| 144 | + | state: update.state, | |
| 145 | + | target: update.target, | |
| 146 | + | branch: update.branch, | |
| 147 | + | pull: update.pull, | |
| 148 | + | issue: update.issue, | |
| 149 | + | error: update.error, | |
| 150 | + | updated_at: update.updated_at, | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | impl From<SecretFinding> for SecurityAlert { | |
| 156 | + | fn from(secret: SecretFinding) -> Self { | |
| 157 | + | let decided = secret.state != AlertState::Open; | |
| 158 | + | SecurityAlert { | |
| 159 | + | kind: AlertKind::Secret, | |
| 160 | + | id: secret.id, | |
| 161 | + | state: secret.state, | |
| 162 | + | detail: AlertDetail::Secret(SecretDetail { | |
| 163 | + | secret_type: secret.kind, | |
| 164 | + | label: secret.label, | |
| 165 | + | path: secret.path, | |
| 166 | + | line: secret.line, | |
| 167 | + | commit: secret.commit, | |
| 168 | + | preview: secret.preview, | |
| 169 | + | status: secret.status, | |
| 170 | + | source: secret.source, | |
| 171 | + | test_value: secret.test_value, | |
| 172 | + | found_by: secret.found_by, | |
| 173 | + | }), | |
| 174 | + | found_at: secret.found_at, | |
| 175 | + | dismissed_reason: secret.dismissed_reason.filter(|_| decided), | |
| 176 | + | dismissed_comment: secret.reason.filter(|_| decided), | |
| 177 | + | dismissed_by: secret.decided_by.filter(|_| decided), | |
| 178 | + | dismissed_at: secret.decided_at.filter(|_| decided), | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | impl From<Vulnerability> for SecurityAlert { | |
| 184 | + | fn from(vulnerability: Vulnerability) -> Self { | |
| 185 | + | let dismissed = vulnerability.state == AlertState::Dismissed; | |
| 186 | + | SecurityAlert { | |
| 187 | + | kind: AlertKind::Dependency, | |
| 188 | + | id: vulnerability.id, | |
| 189 | + | state: vulnerability.state, | |
| 190 | + | detail: AlertDetail::Dependency(DependencyDetail { | |
| 191 | + | ecosystem: vulnerability.ecosystem, | |
| 192 | + | package: vulnerability.package, | |
| 193 | + | version: vulnerability.version, | |
| 194 | + | manifest: vulnerability.manifest, | |
| 195 | + | advisory: vulnerability.advisory, | |
| 196 | + | osv_id: vulnerability.osv_id, | |
| 197 | + | summary: vulnerability.summary, | |
| 198 | + | severity: vulnerability.severity, | |
| 199 | + | fixed_version: vulnerability.fixed_version, | |
| 200 | + | fixed_at: vulnerability.fixed_at, | |
| 201 | + | update: vulnerability.update.map(AlertUpdate::from), | |
| 202 | + | }), | |
| 203 | + | found_at: vulnerability.found_at, | |
| 204 | + | dismissed_reason: vulnerability.dismissed_reason.filter(|_| dismissed), | |
| 205 | + | dismissed_comment: vulnerability.dismissed_comment.filter(|_| dismissed), | |
| 206 | + | dismissed_by: vulnerability.dismissed_by.filter(|_| dismissed), | |
| 207 | + | dismissed_at: vulnerability.dismissed_at.filter(|_| dismissed), | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | impl SecurityAlert { | |
| 213 | + | /// The alert `dismiss` or `reopen` changed, if it names one. | |
| 214 | + | pub fn from_change(change: AlertChange) -> Option<SecurityAlert> { | |
| 215 | + | change | |
| 216 | + | .secret | |
| 217 | + | .map(SecurityAlert::from) | |
| 218 | + | .or_else(|| change.vulnerability.map(SecurityAlert::from)) | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | /// Secrets, then dependencies, filtered by state and kind when given. | |
| 223 | + | pub fn list( | |
| 224 | + | secrets: Vec<SecretFinding>, | |
| 225 | + | vulnerabilities: Vec<Vulnerability>, | |
| 226 | + | state: Option<AlertState>, | |
| 227 | + | kind: Option<AlertKind>, | |
| 228 | + | ) -> Vec<SecurityAlert> { | |
| 229 | + | let secrets = secrets.into_iter().map(SecurityAlert::from); | |
| 230 | + | let dependencies = vulnerabilities.into_iter().map(SecurityAlert::from); | |
| 231 | + | secrets | |
| 232 | + | .chain(dependencies) | |
| 233 | + | .filter(|alert| state.is_none_or(|state| alert.state == state)) | |
| 234 | + | .filter(|alert| kind.is_none_or(|kind| alert.kind == kind)) | |
| 235 | + | .collect() | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | #[cfg(test)] | |
| 239 | + | mod tests { | |
| 240 | + | use super::*; | |
| 241 | + | use serde_json::{Value, json}; | |
| 242 | + | ||
| 243 | + | fn secret(state: AlertState) -> SecretFinding { | |
| 244 | + | serde_json::from_value(json!({ | |
| 245 | + | "id": "sec_1", | |
| 246 | + | "repoId": "rep_1", | |
| 247 | + | "kind": "aws_access_key", | |
| 248 | + | "label": "an AWS access key", | |
| 249 | + | "path": "config/dev.env", | |
| 250 | + | "line": 3, | |
| 251 | + | "commit": "9f2c1e0", | |
| 252 | + | "preview": "AKIA…MPLE", | |
| 253 | + | "status": if state == AlertState::Open { "open" } else { "allowed" }, | |
| 254 | + | "source": "history", | |
| 255 | + | "foundBy": null, | |
| 256 | + | "foundAt": "2026-10-01T12:00:00Z", | |
| 257 | + | "decidedBy": "ada", | |
| 258 | + | "reason": "Only in the test fixtures.", | |
| 259 | + | "decidedAt": "2026-10-02T09:00:00Z", | |
| 260 | + | "dismissedReason": "used_in_tests", | |
| 261 | + | "testValue": "a documented example key", | |
| 262 | + | "state": state, | |
| 263 | + | })) | |
| 264 | + | .unwrap() | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | fn vulnerability() -> Vulnerability { | |
| 268 | + | serde_json::from_value(json!({ | |
| 269 | + | "id": "vul_1", | |
| 270 | + | "repoId": "rep_1", | |
| 271 | + | "ecosystem": "npm", | |
| 272 | + | "package": "lodash", | |
| 273 | + | "version": "4.17.20", | |
| 274 | + | "manifest": "package-lock.json", | |
| 275 | + | "advisory": "GHSA-35jh-r3h4-6jhm", | |
| 276 | + | "osvId": "GHSA-35jh-r3h4-6jhm", | |
| 277 | + | "summary": "Command injection in lodash", | |
| 278 | + | "severity": "high", | |
| 279 | + | "fixedVersion": null, | |
| 280 | + | "status": "open", | |
| 281 | + | "issue": null, | |
| 282 | + | "foundAt": "2026-10-01T12:00:00Z", | |
| 283 | + | "fixedAt": null, | |
| 284 | + | "state": "open", | |
| 285 | + | "update": { "state": "open", "target": "4.17.21", "branch": "g1t/security/lodash-4.17.21", "pull": 12, "issue": null, "error": null, "updatedAt": "2026-10-01T12:05:00Z" }, | |
| 286 | + | })) | |
| 287 | + | .unwrap() | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | fn keys(value: &Value, out: &mut Vec<String>) { | |
| 291 | + | match value { | |
| 292 | + | Value::Object(fields) => { | |
| 293 | + | for (key, value) in fields { | |
| 294 | + | out.push(key.clone()); | |
| 295 | + | keys(value, out); | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | Value::Array(items) => items.iter().for_each(|item| keys(item, out)), | |
| 299 | + | _ => {} | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | #[test] | |
| 304 | + | fn an_alert_is_snake_case_in_one_flat_shape() { | |
| 305 | + | let alerts = list(vec![secret(AlertState::Dismissed)], vec![vulnerability()], None, None); | |
| 306 | + | let sent = serde_json::to_value(&alerts).unwrap(); | |
| 307 | + | assert!(g1t_kit::wire::camel_case_keys(&sent).is_empty(), "{sent}"); | |
| 308 | + | let mut names = Vec::new(); | |
| 309 | + | keys(&sent, &mut names); | |
| 310 | + | for name in names { | |
| 311 | + | assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), "{name}"); | |
| 312 | + | } | |
| 313 | + | let (secret, dependency) = (&sent[0], &sent[1]); | |
| 314 | + | assert_eq!(secret["kind"], "secret"); | |
| 315 | + | assert_eq!(secret["secret_type"], "aws_access_key"); | |
| 316 | + | assert_eq!(secret["dismissed_reason"], "used_in_tests"); | |
| 317 | + | assert_eq!(secret["dismissed_comment"], "Only in the test fixtures."); | |
| 318 | + | assert_eq!(secret["dismissed_by"], "ada"); | |
| 319 | + | assert!(secret.get("package").is_none()); | |
| 320 | + | assert_eq!(dependency["kind"], "dependency"); | |
| 321 | + | // No patched version is a null, not a missing field. | |
| 322 | + | assert!(dependency["fixed_version"].is_null() && dependency.get("fixed_version").is_some()); | |
| 323 | + | assert_eq!(dependency["update"]["updated_at"], "2026-10-01T12:05:00Z"); | |
| 324 | + | assert!(dependency["dismissed_reason"].is_null()); | |
| 325 | + | assert!(dependency.get("secret_type").is_none()); | |
| 326 | + | // And it reads back as it was. | |
| 327 | + | let again: Vec<SecurityAlert> = serde_json::from_value(sent).unwrap(); | |
| 328 | + | assert_eq!(again, alerts); | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | #[test] | |
| 332 | + | fn an_open_secret_shows_no_decision() { | |
| 333 | + | let alert = SecurityAlert::from(secret(AlertState::Open)); | |
| 334 | + | assert_eq!(alert.dismissed_reason, None); | |
| 335 | + | assert_eq!(alert.dismissed_by, None); | |
| 336 | + | assert_eq!(alert.dismissed_comment, None); | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | #[test] | |
| 340 | + | fn alerts_filter_by_state_and_kind() { | |
| 341 | + | let all = || (vec![secret(AlertState::Dismissed)], vec![vulnerability()]); | |
| 342 | + | let (s, v) = all(); | |
| 343 | + | assert_eq!(list(s, v, Some(AlertState::Open), None).len(), 1); | |
| 344 | + | let (s, v) = all(); | |
| 345 | + | let secrets = list(s, v, None, Some(AlertKind::Secret)); | |
| 346 | + | assert_eq!(secrets.len(), 1); | |
| 347 | + | assert_eq!(secrets[0].kind, AlertKind::Secret); | |
| 348 | + | let (s, v) = all(); | |
| 349 | + | assert!(list(s, v, Some(AlertState::Fixed), None).is_empty()); | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | #[test] | |
| 353 | + | fn each_kind_takes_its_own_reasons() { | |
| 354 | + | assert_eq!(AlertKind::Secret.reasons(), ["false_positive", "used_in_tests", "revoked", "wont_fix"]); | |
| 355 | + | assert_eq!( | |
| 356 | + | AlertKind::Dependency.reasons(), | |
| 357 | + | ["fix_started", "no_bandwidth", "tolerable_risk", "inaccurate", "not_used"] | |
| 358 | + | ); | |
| 359 | + | assert_eq!(AlertKind::of_id("sec_9"), Some(AlertKind::Secret)); | |
| 360 | + | assert_eq!(AlertKind::of_id("vul_9"), Some(AlertKind::Dependency)); | |
| 361 | + | assert_eq!(AlertKind::of_id("x"), None); | |
| 362 | + | } | |
| 363 | + | } |
| 4 | 4 | //! operations (see [`operations::Op`]), which call the services that own | |
| 5 | 5 | //! the data. This Worker holds none. | |
| 6 | 6 | ||
| 7 | + | mod alerts; | |
| 7 | 8 | mod audit; | |
| 8 | 9 | mod blobs; | |
| 9 | 10 | mod mcp; |
| 21 | 21 | ( | |
| 22 | 22 | "Workspaces", | |
| 23 | 23 | "A workspace owns repositories and is the first part of their address. People and agents work in workspaces.", | |
| 24 | − | &[Op::CreateWorkspace, Op::DeleteWorkspace], | |
| 24 | + | &[Op::CreateWorkspace, Op::UpdateWorkspace, Op::DeleteWorkspace], | |
| 25 | 25 | ), | |
| 26 | 26 | ( | |
| 27 | 27 | "Invites", | |
| 78 | 78 | ], | |
| 79 | 79 | ), | |
| 80 | 80 | ( | |
| 81 | + | "Security", | |
| 82 | + | "Secrets found in what is pushed and in a repository's history, and dependencies with known vulnerabilities: listing the alerts, and dismissing or reopening them.", | |
| 83 | + | &[Op::ListSecurityAlerts, Op::DismissSecurityAlert, Op::ReopenSecurityAlert], | |
| 84 | + | ), | |
| 85 | + | ( | |
| 81 | 86 | "Issues", | |
| 82 | 87 | "What should change in a repository, with labels and comments. Issues and pull requests share one sequence of numbers.", | |
| 83 | 88 | &[ | |
| 220 | 225 | Op::Whoami => "Get the current user", | |
| 221 | 226 | Op::CreateWorkspace => "Create a workspace", | |
| 222 | 227 | Op::DeleteWorkspace => "Delete a workspace", | |
| 228 | + | Op::UpdateWorkspace => "Update a workspace", | |
| 223 | 229 | Op::ListEmails => "List your email addresses", | |
| 224 | 230 | Op::AddEmail => "Add an email address", | |
| 225 | 231 | Op::RemoveEmail => "Remove an email address", | |
| 330 | 336 | Op::DeclineRepoInvitation => "Decline a repository invitation", | |
| 331 | 337 | Op::SetBasePermission => "Set the base permission", | |
| 332 | 338 | Op::ListOutsideCollaborators => "List outside collaborators", | |
| 339 | + | Op::ListSecurityAlerts => "List security alerts", | |
| 340 | + | Op::DismissSecurityAlert => "Dismiss a security alert", | |
| 341 | + | Op::ReopenSecurityAlert => "Reopen a security alert", | |
| 333 | 342 | } | |
| 334 | 343 | } | |
| 335 | 344 |
| 11 | 11 | }; | |
| 12 | 12 | use g1t_contracts::identity::AgentScope; | |
| 13 | 13 | use g1t_contracts::events::{Event, ListArgs as ListEventsArgs}; | |
| 14 | − | use g1t_contracts::identity::CreateWorkspaceArgs; | |
| 14 | + | use g1t_contracts::identity::{CreateWorkspaceArgs, UpdateWorkspaceArgs, Workspace}; | |
| 15 | 15 | use g1t_contracts::repos::{CreateArgs, GetArgs, ListArgs as ListReposArgs, Repo, RepoPath}; | |
| 16 | + | use g1t_contracts::security::{ | |
| 17 | + | AlertChange, AlertState, DismissArgs, DismissReason, OverviewArgs as SecurityOverviewArgs, ReopenArgs, | |
| 18 | + | SecurityOverview, | |
| 19 | + | }; | |
| 20 | + | ||
| 21 | + | use crate::alerts::{AlertKind, SecurityAlert}; | |
| 16 | 22 | use g1t_contracts::work::*; | |
| 17 | 23 | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 18 | 24 | use serde::Serialize; | |
| 35 | 41 | pub context: Fetcher, | |
| 36 | 42 | /// Search across all of g1t. | |
| 37 | 43 | pub search: Fetcher, | |
| 44 | + | /// Secret and dependency alerts. | |
| 45 | + | pub security: Fetcher, | |
| 38 | 46 | /// Where the request came in, for its audit entries. | |
| 39 | 47 | pub audit: crate::audit::AuditContext, | |
| 40 | 48 | /// Set for a request made with an agent's token: all it may do. | |
| 55 | 63 | actions: env.service("ACTIONS")?, | |
| 56 | 64 | context: env.service("CONTEXT")?, | |
| 57 | 65 | search: env.service("SEARCH")?, | |
| 66 | + | security: env.service("SECURITY")?, | |
| 58 | 67 | scope: None, | |
| 59 | 68 | audit: crate::audit::AuditContext::default(), | |
| 60 | 69 | }) | |
| 66 | 75 | Whoami, | |
| 67 | 76 | CreateWorkspace, | |
| 68 | 77 | DeleteWorkspace, | |
| 78 | + | UpdateWorkspace, | |
| 69 | 79 | ListEmails, | |
| 70 | 80 | AddEmail, | |
| 71 | 81 | RemoveEmail, | |
| 176 | 186 | DeclineRepoInvitation, | |
| 177 | 187 | SetBasePermission, | |
| 178 | 188 | ListOutsideCollaborators, | |
| 189 | + | ListSecurityAlerts, | |
| 190 | + | DismissSecurityAlert, | |
| 191 | + | ReopenSecurityAlert, | |
| 179 | 192 | } | |
| 180 | 193 | ||
| 181 | 194 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| 397 | 410 | }) | |
| 398 | 411 | } | |
| 399 | 412 | ||
| 413 | + | fn alert_id_schema() -> Value { | |
| 414 | + | json!({ | |
| 415 | + | "type": "string", | |
| 416 | + | "description": "The alert's id, from list_security_alerts: sec_… for a secret, vul_… for a dependency.", | |
| 417 | + | }) | |
| 418 | + | } | |
| 419 | + | ||
| 400 | 420 | impl Op { | |
| 401 | − | pub const ALL: [Op; 113] = [ | |
| 421 | + | pub const ALL: [Op; 117] = [ | |
| 402 | 422 | Op::Whoami, | |
| 403 | 423 | Op::CreateWorkspace, | |
| 404 | 424 | Op::DeleteWorkspace, | |
| 425 | + | Op::UpdateWorkspace, | |
| 405 | 426 | Op::ListEmails, | |
| 406 | 427 | Op::AddEmail, | |
| 407 | 428 | Op::RemoveEmail, | |
| 512 | 533 | Op::DeclineRepoInvitation, | |
| 513 | 534 | Op::SetBasePermission, | |
| 514 | 535 | Op::ListOutsideCollaborators, | |
| 536 | + | Op::ListSecurityAlerts, | |
| 537 | + | Op::DismissSecurityAlert, | |
| 538 | + | Op::ReopenSecurityAlert, | |
| 515 | 539 | ]; | |
| 516 | 540 | ||
| 517 | 541 | pub fn by_name(name: &str) -> Option<Op> { | |
| 524 | 548 | Op::Whoami => "whoami", | |
| 525 | 549 | Op::CreateWorkspace => "create_workspace", | |
| 526 | 550 | Op::DeleteWorkspace => "delete_workspace", | |
| 551 | + | Op::UpdateWorkspace => "update_workspace", | |
| 527 | 552 | Op::ListEmails => "list_emails", | |
| 528 | 553 | Op::AddEmail => "add_email", | |
| 529 | 554 | Op::RemoveEmail => "remove_email", | |
| 634 | 659 | Op::DeclineRepoInvitation => "decline_repo_invitation", | |
| 635 | 660 | Op::SetBasePermission => "set_base_permission", | |
| 636 | 661 | Op::ListOutsideCollaborators => "list_outside_collaborators", | |
| 662 | + | Op::ListSecurityAlerts => "list_security_alerts", | |
| 663 | + | Op::DismissSecurityAlert => "dismiss_security_alert", | |
| 664 | + | Op::ReopenSecurityAlert => "reopen_security_alert", | |
| 637 | 665 | } | |
| 638 | 666 | } | |
| 639 | 667 | ||
| 676 | 704 | Op::DeleteWorkspace => { | |
| 677 | 705 | "Delete a workspace. Owners only, signed in as a person, and confirm must be the workspace's slug. It must hold no repositories (move them with transfer_repo first) and no projects, and billing must be able to settle it: no unpaid invoice, no prepaid credit left, and no usage this month still being metered; what it owes is charged to its card at once. Its members, access tokens, webhooks, integrations and workspace secrets are removed; its statements, invoices and audit log are kept. The slug is never given to another workspace; the person whose username it is may create it again." | |
| 678 | 706 | } | |
| 707 | + | Op::UpdateWorkspace => { | |
| 708 | + | "Change a workspace's display name and description, and what every member gets on each of its repositories (base_permission: none, read, write or admin). Only the fields given are changed; give at least one. An empty name falls back to the slug, which this never changes (that is a rename, on Settings); an empty description clears it. Owners only, signed in as a person. Returns the workspace as it is now." | |
| 709 | + | } | |
| 679 | 710 | Op::ListRepos => "Repositories you can see, optionally filtered by a search query.", | |
| 680 | 711 | Op::GetRepo => "One repository's details.", | |
| 681 | 712 | Op::UpdateRepo => { | |
| 944 | 975 | Op::ListOutsideCollaborators => { | |
| 945 | 976 | "The people with a role on some of a workspace's repositories who are not its members, each with the repositories they can reach and their role on each. Owners only." | |
| 946 | 977 | } | |
| 978 | + | Op::ListSecurityAlerts => { | |
| 979 | + | "A repository's security alerts: secrets found in what was pushed or in its history (`kind` `secret`), and dependencies with a known vulnerability (`kind` `dependency`), secrets first. Each has a `state`: `open`, `dismissed` (someone said why it can stay) or `fixed` (a secret revoked, a dependency no longer vulnerable). Filter with `state` and `kind`; both are left out for all. A secret is never returned, only a `preview`. Needs the Write role on the repository; anyone else is told it does not exist, whether or not the repository is public." | |
| 980 | + | } | |
| 981 | + | Op::DismissSecurityAlert => { | |
| 982 | + | "Dismiss an alert with a reason and an optional comment. A secret takes false_positive, used_in_tests, revoked or wont_fix; a dependency takes fix_started, no_bandwidth, tolerable_risk, inaccurate or not_used. A dismissed secret is let through push protection from then on, unless the reason is `revoked`, which marks it fixed, so dismissing a secret needs the Admin role on the repository; a dependency needs Write. Returns the alert as it is now. Reopen it with reopen_security_alert." | |
| 983 | + | } | |
| 984 | + | Op::ReopenSecurityAlert => { | |
| 985 | + | "Open a dismissed alert again. A reopened secret stops pushes that carry it again. The same roles as dismissing: Admin for a secret, Write for a dependency. Returns the alert as it is now." | |
| 986 | + | } | |
| 947 | 987 | } | |
| 948 | 988 | } | |
| 949 | 989 | ||
| 1047 | 1087 | }), | |
| 1048 | 1088 | &["workspace", "confirm"], | |
| 1049 | 1089 | ), | |
| 1090 | + | Op::UpdateWorkspace => object( | |
| 1091 | + | json!({ | |
| 1092 | + | "workspace": workspace_schema(), | |
| 1093 | + | "name": { | |
| 1094 | + | "type": "string", | |
| 1095 | + | "description": "Its display name, at most 80 characters; longer is cut. Empty: its slug.", | |
| 1096 | + | }, | |
| 1097 | + | "description": { | |
| 1098 | + | "type": "string", | |
| 1099 | + | "description": "One line saying what it is for, at most 160 characters; longer is cut. Empty clears it.", | |
| 1100 | + | }, | |
| 1101 | + | "base_permission": { | |
| 1102 | + | "type": "string", | |
| 1103 | + | "enum": g1t_contracts::access::BasePermission::ALL.map(|base| base.as_str()), | |
| 1104 | + | "description": "What every member gets on each repository: none, read, write or admin. Needs the access:admin scope as well.", | |
| 1105 | + | }, | |
| 1106 | + | }), | |
| 1107 | + | &["workspace"], | |
| 1108 | + | ), | |
| 1050 | 1109 | Op::TransferRepo => object( | |
| 1051 | 1110 | json!({ | |
| 1052 | 1111 | "repo": repo_schema(), | |
| 1784 | 1843 | &["workspace", "base_permission"], | |
| 1785 | 1844 | ), | |
| 1786 | 1845 | Op::ListOutsideCollaborators => object(json!({ "workspace": workspace_schema() }), &["workspace"]), | |
| 1846 | + | Op::ListSecurityAlerts => object( | |
| 1847 | + | json!({ | |
| 1848 | + | "repo": repo_schema(), | |
| 1849 | + | "state": { | |
| 1850 | + | "type": "string", | |
| 1851 | + | "enum": ([AlertState::Open, AlertState::Dismissed, AlertState::Fixed].map(AlertState::as_str)), | |
| 1852 | + | "description": "Only alerts in this state. Left out for all.", | |
| 1853 | + | }, | |
| 1854 | + | "kind": { | |
| 1855 | + | "type": "string", | |
| 1856 | + | "enum": AlertKind::ALL.map(AlertKind::as_str), | |
| 1857 | + | "description": "Only secrets, or only vulnerable dependencies. Left out for both.", | |
| 1858 | + | }, | |
| 1859 | + | }), | |
| 1860 | + | &["repo"], | |
| 1861 | + | ), | |
| 1862 | + | Op::DismissSecurityAlert => object( | |
| 1863 | + | json!({ | |
| 1864 | + | "repo": repo_schema(), | |
| 1865 | + | "id": alert_id_schema(), | |
| 1866 | + | "reason": { | |
| 1867 | + | "type": "string", | |
| 1868 | + | "enum": DismissReason::ALL.map(DismissReason::as_str), | |
| 1869 | + | "description": "Why it can stay. For a secret: false_positive, used_in_tests, revoked (it was rotated: the alert is fixed) or wont_fix. For a dependency: fix_started, no_bandwidth, tolerable_risk, inaccurate or not_used.", | |
| 1870 | + | }, | |
| 1871 | + | "comment": { "type": "string", "description": "More about why, for whoever reads the alert next." }, | |
| 1872 | + | }), | |
| 1873 | + | &["repo", "id", "reason"], | |
| 1874 | + | ), | |
| 1875 | + | Op::ReopenSecurityAlert => object(json!({ "repo": repo_schema(), "id": alert_id_schema() }), &["repo", "id"]), | |
| 1787 | 1876 | } | |
| 1788 | 1877 | } | |
| 1789 | 1878 | ||
| 1820 | 1909 | Op::Whoami | |
| 1821 | 1910 | | Op::CreateWorkspace | |
| 1822 | 1911 | | Op::DeleteWorkspace | |
| 1912 | + | | Op::UpdateWorkspace | |
| 1823 | 1913 | | Op::ListEmails | |
| 1824 | 1914 | | Op::AddEmail | |
| 1825 | 1915 | | Op::RemoveEmail | |
| 2101 | 2191 | ) | |
| 2102 | 2192 | .await | |
| 2103 | 2193 | } | |
| 2194 | + | Op::UpdateWorkspace => { | |
| 2195 | + | let base = match input.get("base_permission").filter(|value| !value.is_null()) { | |
| 2196 | + | None => None, | |
| 2197 | + | Some(value) => match value.as_str().and_then(BasePermission::parse) { | |
| 2198 | + | Some(base) => Some(base), | |
| 2199 | + | None => return failed(FailureCode::Invalid, "base_permission is none, read, write or admin."), | |
| 2200 | + | }, | |
| 2201 | + | }; | |
| 2202 | + | let (name, description) = (optional_text(input, "name"), optional_text(input, "description")); | |
| 2203 | + | if base.is_none() && name.is_none() && description.is_none() { | |
| 2204 | + | return failed(FailureCode::Invalid, "Give name, description or base_permission to change."); | |
| 2205 | + | } | |
| 2206 | + | let found = || async { | |
| 2207 | + | g1t_kit::call::<_, Option<Workspace>>(identity, "get_workspace", &json!({ "slug": workspace() })).await | |
| 2208 | + | }; | |
| 2209 | + | if name.is_some() || description.is_some() { | |
| 2210 | + | // Identity sets both: what was not given stays as it is. | |
| 2211 | + | let Some(current) = found().await? else { | |
| 2212 | + | return failed(FailureCode::NotFound, "Workspace not found."); | |
| 2213 | + | }; | |
| 2214 | + | let updated: Outcome<Workspace> = call( | |
| 2215 | + | identity, | |
| 2216 | + | "update_workspace", | |
| 2217 | + | &UpdateWorkspaceArgs { | |
| 2218 | + | actor: actor(), | |
| 2219 | + | slug: workspace(), | |
| 2220 | + | name: name.unwrap_or(current.name), | |
| 2221 | + | description: description.unwrap_or(current.description.unwrap_or_default()), | |
| 2222 | + | }, | |
| 2223 | + | ) | |
| 2224 | + | .await?; | |
| 2225 | + | if let Outcome::Fail(failure) = updated { | |
| 2226 | + | return Ok(Outcome::Fail(failure)); | |
| 2227 | + | } | |
| 2228 | + | } | |
| 2229 | + | if let Some(base) = base { | |
| 2230 | + | let set: Outcome<BasePermission> = call( | |
| 2231 | + | identity, | |
| 2232 | + | "set_base_permission", | |
| 2233 | + | &SetBasePermissionArgs { | |
| 2234 | + | actor: actor(), | |
| 2235 | + | slug: workspace(), | |
| 2236 | + | base_permission: base, | |
| 2237 | + | surface: Some(services.audit.surface), | |
| 2238 | + | }, | |
| 2239 | + | ) | |
| 2240 | + | .await?; | |
| 2241 | + | if let Outcome::Fail(failure) = set { | |
| 2242 | + | return Ok(Outcome::Fail(failure)); | |
| 2243 | + | } | |
| 2244 | + | } | |
| 2245 | + | match found().await? { | |
| 2246 | + | Some(workspace) => ok(&workspace), | |
| 2247 | + | None => failed(FailureCode::NotFound, "Workspace not found."), | |
| 2248 | + | } | |
| 2249 | + | } | |
| 2104 | 2250 | Op::TransferRepo => { | |
| 2105 | 2251 | pass( | |
| 2106 | 2252 | repos, | |
| 3178 | 3324 | ) | |
| 3179 | 3325 | .await | |
| 3180 | 3326 | } | |
| 3327 | + | // Security alerts: the security service decides who may see and | |
| 3328 | + | // change them; the API gives them one public shape. | |
| 3329 | + | Op::ListSecurityAlerts => { | |
| 3330 | + | let filters = match alert_filters(input) { | |
| 3331 | + | Ok(filters) => filters, | |
| 3332 | + | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 3333 | + | }; | |
| 3334 | + | let overview: Outcome<SecurityOverview> = call( | |
| 3335 | + | &services.security, | |
| 3336 | + | "overview", | |
| 3337 | + | &SecurityOverviewArgs { repo, viewer: viewer.clone() }, | |
| 3338 | + | ) | |
| 3339 | + | .await?; | |
| 3340 | + | match overview { | |
| 3341 | + | Outcome::Ok(overview) => ok(&crate::alerts::list( | |
| 3342 | + | overview.secrets, | |
| 3343 | + | overview.vulnerabilities, | |
| 3344 | + | filters.0, | |
| 3345 | + | filters.1, | |
| 3346 | + | )), | |
| 3347 | + | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 3348 | + | } | |
| 3349 | + | } | |
| 3350 | + | Op::DismissSecurityAlert => { | |
| 3351 | + | let id = text(input, "id"); | |
| 3352 | + | let reason = match dismiss_reason(input, &id) { | |
| 3353 | + | Ok(reason) => reason, | |
| 3354 | + | Err(message) => return failed(FailureCode::Invalid, &message), | |
| 3355 | + | }; | |
| 3356 | + | let comment = text(input, "comment").trim().to_owned(); | |
| 3357 | + | let changed: Outcome<AlertChange> = call( | |
| 3358 | + | &services.security, | |
| 3359 | + | "dismiss", | |
| 3360 | + | &DismissArgs { actor: actor(), repo, id, reason, comment }, | |
| 3361 | + | ) | |
| 3362 | + | .await?; | |
| 3363 | + | changed_alert(changed) | |
| 3364 | + | } | |
| 3365 | + | Op::ReopenSecurityAlert => { | |
| 3366 | + | let changed: Outcome<AlertChange> = call( | |
| 3367 | + | &services.security, | |
| 3368 | + | "reopen", | |
| 3369 | + | &ReopenArgs { actor: actor(), repo, id: text(input, "id") }, | |
| 3370 | + | ) | |
| 3371 | + | .await?; | |
| 3372 | + | changed_alert(changed) | |
| 3373 | + | } | |
| 3181 | 3374 | } | |
| 3182 | 3375 | } | |
| 3183 | 3376 | } | |
| 3184 | 3377 | ||
| 3378 | + | /// `state` and `kind`, as list_security_alerts reads them. | |
| 3379 | + | fn alert_filters(input: &Value) -> std::result::Result<(Option<AlertState>, Option<AlertKind>), String> { | |
| 3380 | + | let state = match optional_text(input, "state") { | |
| 3381 | + | None => None, | |
| 3382 | + | Some(state) => Some( | |
| 3383 | + | AlertState::parse(&state.to_lowercase()) | |
| 3384 | + | .ok_or_else(|| format!("state is open, dismissed or fixed, not {state}."))?, | |
| 3385 | + | ), | |
| 3386 | + | }; | |
| 3387 | + | let kind = match optional_text(input, "kind") { | |
| 3388 | + | None => None, | |
| 3389 | + | Some(kind) => Some( | |
| 3390 | + | AlertKind::parse(&kind.to_lowercase()) | |
| 3391 | + | .ok_or_else(|| format!("kind is secret or dependency, not {kind}."))?, | |
| 3392 | + | ), | |
| 3393 | + | }; | |
| 3394 | + | Ok((state, kind)) | |
| 3395 | + | } | |
| 3396 | + | ||
| 3397 | + | /// The reason dismiss_security_alert was given, checked against the kind | |
| 3398 | + | /// of alert its id names. | |
| 3399 | + | fn dismiss_reason(input: &Value, id: &str) -> std::result::Result<DismissReason, String> { | |
| 3400 | + | let all = || DismissReason::ALL.map(DismissReason::as_str).join(", "); | |
| 3401 | + | let given = text(input, "reason"); | |
| 3402 | + | let Some(reason) = DismissReason::parse(given.trim()) else { | |
| 3403 | + | return Err(if given.is_empty() { | |
| 3404 | + | format!("Give a reason: one of {}.", all()) | |
| 3405 | + | } else { | |
| 3406 | + | format!("{given} is not a reason. Give one of {}.", all()) | |
| 3407 | + | }); | |
| 3408 | + | }; | |
| 3409 | + | match AlertKind::of_id(id) { | |
| 3410 | + | Some(kind) if !kind.takes(reason) => Err(format!( | |
| 3411 | + | "A {} alert is dismissed with {}, not {}.", | |
| 3412 | + | kind.as_str(), | |
| 3413 | + | kind.reasons().join(", "), | |
| 3414 | + | reason.as_str() | |
| 3415 | + | )), | |
| 3416 | + | _ => Ok(reason), | |
| 3417 | + | } | |
| 3418 | + | } | |
| 3419 | + | ||
| 3420 | + | /// The alert dismiss or reopen changed, in its public shape. | |
| 3421 | + | fn changed_alert(changed: Outcome<AlertChange>) -> Result<Outcome<Value>> { | |
| 3422 | + | match changed { | |
| 3423 | + | Outcome::Ok(change) => match SecurityAlert::from_change(change) { | |
| 3424 | + | Some(alert) => ok(&alert), | |
| 3425 | + | None => failed(FailureCode::NotFound, "No such alert."), | |
| 3426 | + | }, | |
| 3427 | + | Outcome::Fail(failure) => Ok(Outcome::Fail(failure)), | |
| 3428 | + | } | |
| 3429 | + | } | |
| 3430 | + | ||
| 3185 | 3431 | const ROLE_NEEDED: &str = "Give a role: read, triage, write, maintain or admin."; | |
| 3186 | 3432 | ||
| 3187 | 3433 | /// The role named by `role`. | |
| 3313 | 3559 | assert!(op.needs_user(), "{}", op.name()); | |
| 3314 | 3560 | } | |
| 3315 | 3561 | } | |
| 3562 | + | ||
| 3563 | + | /// An unknown reason, or one for the other kind of alert, is refused | |
| 3564 | + | /// before the security service is asked. | |
| 3565 | + | #[test] | |
| 3566 | + | fn dismiss_reasons_are_checked_against_the_alert() { | |
| 3567 | + | let reason = |reason: &str, id: &str| dismiss_reason(&json!({ "reason": reason }), id); | |
| 3568 | + | assert_eq!(reason("used_in_tests", "sec_1"), Ok(DismissReason::UsedInTests)); | |
| 3569 | + | assert_eq!(reason("tolerable_risk", "vul_1"), Ok(DismissReason::TolerableRisk)); | |
| 3570 | + | assert!(reason("because", "sec_1").unwrap_err().contains("not a reason")); | |
| 3571 | + | assert!(reason("", "sec_1").unwrap_err().starts_with("Give a reason")); | |
| 3572 | + | assert!(reason("not_used", "sec_1").unwrap_err().contains("false_positive")); | |
| 3573 | + | assert!(reason("revoked", "vul_1").unwrap_err().contains("fix_started")); | |
| 3574 | + | assert_eq!( | |
| 3575 | + | Op::DismissSecurityAlert.input()["properties"]["reason"]["enum"].as_array().unwrap().len(), | |
| 3576 | + | DismissReason::ALL.len() | |
| 3577 | + | ); | |
| 3578 | + | } | |
| 3579 | + | ||
| 3580 | + | #[test] | |
| 3581 | + | fn alert_filters_are_read_as_words() { | |
| 3582 | + | assert_eq!(alert_filters(&json!({})), Ok((None, None))); | |
| 3583 | + | assert_eq!( | |
| 3584 | + | alert_filters(&json!({ "state": "Dismissed", "kind": "secret" })), | |
| 3585 | + | Ok((Some(AlertState::Dismissed), Some(AlertKind::Secret))) | |
| 3586 | + | ); | |
| 3587 | + | assert!(alert_filters(&json!({ "state": "closed" })).is_err()); | |
| 3588 | + | assert!(alert_filters(&json!({ "kind": "vulnerability" })).is_err()); | |
| 3589 | + | } | |
| 3590 | + | ||
| 3591 | + | /// An agent's token reads alerts at most; it never dismisses or | |
| 3592 | + | /// reopens one, whatever its scope lists. | |
| 3593 | + | #[test] | |
| 3594 | + | fn agents_never_dismiss_alerts() { | |
| 3595 | + | use g1t_contracts::credentials::NEVER; | |
| 3596 | + | for op in [Op::DismissSecurityAlert, Op::ReopenSecurityAlert] { | |
| 3597 | + | assert!(NEVER.contains(&op.name()), "{}", op.name()); | |
| 3598 | + | } | |
| 3599 | + | assert!(!NEVER.contains(&Op::ListSecurityAlerts.name())); | |
| 3600 | + | } | |
| 3316 | 3601 | } |
| 62 | 62 | "member_count": 1, | |
| 63 | 63 | "base_permission": "write" | |
| 64 | 64 | }, | |
| 65 | − | "notes": "`base_permission` is what every member gets on each of its repositories: `write` until an owner changes it with `PATCH /workspaces/{workspace}`. See [Access and roles](/guides/access-and-roles/)." | |
| 65 | + | "notes": "`base_permission` is what every member gets on each of its repositories: `write` until an owner changes it with `PATCH /workspaces/{workspace}` or `PUT /workspaces/{workspace}/base_permission`. See [Access and roles](/guides/access-and-roles/)." | |
| 66 | 66 | }, | |
| 67 | + | "update_workspace": { | |
| 68 | + | "params": { | |
| 69 | + | "workspace": "acme-labs" | |
| 70 | + | }, | |
| 71 | + | "request": { | |
| 72 | + | "name": "Acme Labs", | |
| 73 | + | "description": "Rockets, and the software that flies them." | |
| 74 | + | }, | |
| 75 | + | "response": { | |
| 76 | + | "id": "wsp_01m43teqa9em6bje0bhvdj4jkb", | |
| 77 | + | "slug": "acme-labs", | |
| 78 | + | "name": "Acme Labs", | |
| 79 | + | "description": "Rockets, and the software that flies them.", | |
| 80 | + | "created_at": "2026-10-04T16:02:51.337Z", | |
| 81 | + | "member_count": 3, | |
| 82 | + | "base_permission": "write" | |
| 83 | + | }, | |
| 84 | + | "notes": "Only the fields given change. `name` is the display name; the slug, the first part of the workspace's addresses, stays as it is. `base_permission` needs the `access:admin` scope as well as `workspace:admin`; [`set_base_permission`](/reference/api/access/set-base-permission/) sets it alone. Refused with `403` for anyone but an owner signed in as a person. Recorded in the [audit log](/guides/audit-log/). See [Workspaces](/guides/workspaces/)." | |
| 85 | + | }, | |
| 67 | 86 | "delete_workspace": { | |
| 68 | 87 | "request": { | |
| 69 | 88 | "confirm": "acme-labs" | |
| 732 | 751 | "updated_by": "syntaqx", | |
| 733 | 752 | "updated_at": "2026-10-04T16:20:37.508Z" | |
| 734 | 753 | }, | |
| 735 | − | "notes": "`required_checks` replaces the whole list: at most 20 names, each a workflow's name or another status's context, as [`list_check_names`](#list_check_names) gives them. A pull request merges only once each passes on its head; one nothing has reported holds it too. With the merge queue on, each must also pass on the queued state, so the workflows behind them need `merge_group` in their `on:`. `required_approvals` is at most 6 and `max_revisions` at most 5. See [what a repository can ask for](/guides/g1t-agents/#what-a-repository-can-ask-for). `hold_low_confidence` is on unless turned off: see [confidence](/guides/g1t-agents/#how-sure-the-agent-is)." | |
| 754 | + | "notes": "`required_checks` replaces the whole list: at most 20 names, each a workflow's name or another status's context, as [`list_check_names`](/reference/api/repositories/list-check-names/) gives them. A pull request merges only once each passes on its head; one nothing has reported holds it too. With the merge queue on, each must also pass on the queued state, so the workflows behind them need `merge_group` in their `on:`. `required_approvals` is at most 6 and `max_revisions` at most 5. See [what a repository can ask for](/guides/g1t-agents/#what-a-repository-can-ask-for). `hold_low_confidence` is on unless turned off: see [confidence](/guides/g1t-agents/#how-sure-the-agent-is)." | |
| 736 | 755 | }, | |
| 737 | 756 | "list_check_names": { | |
| 738 | 757 | "response": [ | |
| 1625 | 1644 | } | |
| 1626 | 1645 | ] | |
| 1627 | 1646 | }, | |
| 1628 | − | "notes": "| Field | |\n| --- | --- |\n| `pull.files` | The files it changes, with lines added and removed. |\n| `statuses` | Its checks: what each workflow run (or another tool, such as a deployment) reported on its head, as `pending`, `success`, `failure` or `error`, with a link to the run. |\n| `required_checks` | Each check the default branch requires (see [`update_repo_settings`](#update_repo_settings)), as it stands on the head: `success`, `failure`, `pending`, or `expected` when nothing has reported it yet. It merges only once all are `success`. |\n| `checks` | Set when the merge queue took it out, with why in `error`. Older pull requests may show a run of commands from their issue here, from before checks were workflows. |\n| `overlaps` | Other pull requests in progress that change the same files. |\n| `behind` | Whether the default branch has moved since it was made. |\n| `landing` | Whether it is being brought up to date to land. |\n| `lifecycle` | For a pull request the g1t agent is seeing through: its stage, such as `working`, `checking`, `reviewing` or `needs_you`. |\n| `comments` | Comments, reviews and events, with `path`, `line` and `verdict`. |\n| `messages` | Messages sent to the agent working on it. |\n\n| `mergeable` | Whether it merges cleanly into its target: `clean`, `conflicting`, `checking` (being worked out) or `unknown`. Worked out ahead of time whenever it or its target moves. |\n| `conflicts` | When `mergeable` is `conflicting`, the files that conflict. Resolve them by merging the target in, or have the g1t agent do it. |\n| `earlier_checks` | Earlier records like `checks`, newest first. |\n\nChecks are the repository's workflows: they run on `pull_request` events when it is opened, marked ready and pushed to. Read why one failed with [`get_workflow_run`](#get_workflow_run) and [`get_job_logs`](#get_job_logs)." | |
| 1647 | + | "notes": "| Field | |\n| --- | --- |\n| `pull.files` | The files it changes, with lines added and removed. |\n| `statuses` | Its checks: what each workflow run (or another tool, such as a deployment) reported on its head, as `pending`, `success`, `failure` or `error`, with a link to the run. |\n| `required_checks` | Each check the default branch requires (see [`update_repo_settings`](/reference/api/repositories/update-repo-settings/)), as it stands on the head: `success`, `failure`, `pending`, or `expected` when nothing has reported it yet. It merges only once all are `success`. |\n| `checks` | Set when the merge queue took it out, with why in `error`. Older pull requests may show a run of commands from their issue here, from before checks were workflows. |\n| `overlaps` | Other pull requests in progress that change the same files. |\n| `behind` | Whether the default branch has moved since it was made. |\n| `landing` | Whether it is being brought up to date to land. |\n| `lifecycle` | For a pull request the g1t agent is seeing through: its stage, such as `working`, `checking`, `reviewing` or `needs_you`. |\n| `comments` | Comments, reviews and events, with `path`, `line` and `verdict`. |\n| `messages` | Messages sent to the agent working on it. |\n\n| `mergeable` | Whether it merges cleanly into its target: `clean`, `conflicting`, `checking` (being worked out) or `unknown`. Worked out ahead of time whenever it or its target moves. |\n| `conflicts` | When `mergeable` is `conflicting`, the files that conflict. Resolve them by merging the target in, or have the g1t agent do it. |\n| `earlier_checks` | Earlier records like `checks`, newest first. |\n\nChecks are the repository's workflows: they run on `pull_request` events when it is opened, marked ready and pushed to. Read why one failed with [`get_workflow_run`](/reference/api/actions/get-workflow-run/) and [`get_job_logs`](/reference/api/actions/get-job-logs/)." | |
| 1629 | 1648 | }, | |
| 1630 | 1649 | "get_pull_request_changes": { | |
| 1631 | 1650 | "response": { | |
| 4054 | 4073 | } | |
| 4055 | 4074 | ], | |
| 4056 | 4075 | "notes": "By username, each with the repositories they have a role on. Refused with `403` for anyone but an owner. See [Access and roles](/guides/access-and-roles/)." | |
| 4076 | + | }, | |
| 4077 | + | "list_security_alerts": { | |
| 4078 | + | "params": { | |
| 4079 | + | "owner": "flagon-io", | |
| 4080 | + | "name": "hello" | |
| 4081 | + | }, | |
| 4082 | + | "query": { | |
| 4083 | + | "state": "open" | |
| 4084 | + | }, | |
| 4085 | + | "response": [ | |
| 4086 | + | { | |
| 4087 | + | "kind": "secret", | |
| 4088 | + | "id": "sec_01kp4a7b2c3d4e5f6g7h8j9k0m", | |
| 4089 | + | "state": "dismissed", | |
| 4090 | + | "secret_type": "aws_access_key", | |
| 4091 | + | "label": "an AWS access key", | |
| 4092 | + | "path": "test/fixtures/aws.env", | |
| 4093 | + | "line": 3, | |
| 4094 | + | "commit": "9f2c1e04b7d3a8e6f5c2b1a0d9e8f7c6b5a4d3e2", | |
| 4095 | + | "preview": "AKIA…MPLE", | |
| 4096 | + | "status": "allowed", | |
| 4097 | + | "source": "history", | |
| 4098 | + | "test_value": "the documented example key", | |
| 4099 | + | "found_by": null, | |
| 4100 | + | "found_at": "2026-10-01T12:00:00.000Z", | |
| 4101 | + | "dismissed_reason": "used_in_tests", | |
| 4102 | + | "dismissed_comment": "Only in the test fixtures.", | |
| 4103 | + | "dismissed_by": "syntaqx", | |
| 4104 | + | "dismissed_at": "2026-10-02T09:15:00.000Z" | |
| 4105 | + | }, | |
| 4106 | + | { | |
| 4107 | + | "kind": "dependency", | |
| 4108 | + | "id": "vul_01kp4b8c3d4e5f6g7h8j9k0m1n", | |
| 4109 | + | "state": "open", | |
| 4110 | + | "ecosystem": "npm", | |
| 4111 | + | "package": "lodash", | |
| 4112 | + | "version": "4.17.20", | |
| 4113 | + | "manifest": "package-lock.json", | |
| 4114 | + | "advisory": "GHSA-35jh-r3h4-6jhm", | |
| 4115 | + | "osv_id": "GHSA-35jh-r3h4-6jhm", | |
| 4116 | + | "summary": "Command Injection in lodash", | |
| 4117 | + | "severity": "high", | |
| 4118 | + | "fixed_version": "4.17.21", | |
| 4119 | + | "fixed_at": null, | |
| 4120 | + | "update": { | |
| 4121 | + | "state": "open", | |
| 4122 | + | "target": "4.17.21", | |
| 4123 | + | "branch": "g1t/security/lodash-4.17.21", | |
| 4124 | + | "pull": 42, | |
| 4125 | + | "issue": null, | |
| 4126 | + | "error": null, | |
| 4127 | + | "updated_at": "2026-10-01T12:20:00.000Z" | |
| 4128 | + | }, | |
| 4129 | + | "found_at": "2026-10-01T12:00:00.000Z", | |
| 4130 | + | "dismissed_reason": null, | |
| 4131 | + | "dismissed_comment": null, | |
| 4132 | + | "dismissed_by": null, | |
| 4133 | + | "dismissed_at": null | |
| 4134 | + | } | |
| 4135 | + | ], | |
| 4136 | + | "notes": "Secrets come first, then dependencies. Fields only one kind has are left out of the other: a secret has `secret_type`, `label`, `path`, `line`, `commit`, `preview`, `status` (`open`, `blocked`, `allowed` or `resolved`), `source` (`push` or `history`), `test_value` and `found_by`; a dependency has `ecosystem`, `package`, `version`, `manifest`, `advisory`, `osv_id`, `summary`, `severity`, `fixed_version` (null when no patched version is available), `fixed_at` and `update`, the pull request g1t opens to upgrade it. `dismissed_reason`, `dismissed_comment`, `dismissed_by` and `dismissed_at` are null while an alert is open; a secret fixed by being revoked keeps them. `422` for a `state` or `kind` it does not know, and `404` for anyone without the Write role. See [Security](/guides/security/)." | |
| 4137 | + | }, | |
| 4138 | + | "dismiss_security_alert": { | |
| 4139 | + | "params": { | |
| 4140 | + | "owner": "flagon-io", | |
| 4141 | + | "name": "hello", | |
| 4142 | + | "id": "vul_01kp4b8c3d4e5f6g7h8j9k0m1n" | |
| 4143 | + | }, | |
| 4144 | + | "request": { | |
| 4145 | + | "reason": "tolerable_risk", | |
| 4146 | + | "comment": "Only the build uses it, on trusted input." | |
| 4147 | + | }, | |
| 4148 | + | "response": { | |
| 4149 | + | "kind": "dependency", | |
| 4150 | + | "id": "vul_01kp4b8c3d4e5f6g7h8j9k0m1n", | |
| 4151 | + | "state": "dismissed", | |
| 4152 | + | "ecosystem": "npm", | |
| 4153 | + | "package": "lodash", | |
| 4154 | + | "version": "4.17.20", | |
| 4155 | + | "manifest": "package-lock.json", | |
| 4156 | + | "advisory": "GHSA-35jh-r3h4-6jhm", | |
| 4157 | + | "osv_id": "GHSA-35jh-r3h4-6jhm", | |
| 4158 | + | "summary": "Command Injection in lodash", | |
| 4159 | + | "severity": "high", | |
| 4160 | + | "fixed_version": "4.17.21", | |
| 4161 | + | "fixed_at": null, | |
| 4162 | + | "update": { | |
| 4163 | + | "state": "open", | |
| 4164 | + | "target": "4.17.21", | |
| 4165 | + | "branch": "g1t/security/lodash-4.17.21", | |
| 4166 | + | "pull": 42, | |
| 4167 | + | "issue": null, | |
| 4168 | + | "error": null, | |
| 4169 | + | "updated_at": "2026-10-01T12:20:00.000Z" | |
| 4170 | + | }, | |
| 4171 | + | "found_at": "2026-10-01T12:00:00.000Z", | |
| 4172 | + | "dismissed_reason": "tolerable_risk", | |
| 4173 | + | "dismissed_comment": "Only the build uses it, on trusted input.", | |
| 4174 | + | "dismissed_by": "syntaqx", | |
| 4175 | + | "dismissed_at": "2026-10-06T10:00:00.000Z" | |
| 4176 | + | }, | |
| 4177 | + | "notes": "| Kind | Reasons |\n| --- | --- |\n| `secret` | `false_positive`, `used_in_tests`, `revoked`, `wont_fix` |\n| `dependency` | `fix_started`, `no_bandwidth`, `tolerable_risk`, `inaccurate`, `not_used` |\n\nA reason for the other kind of alert, or one not in the table, is refused with `422`. A secret dismissed as `revoked` becomes `fixed`; with any other reason it becomes `dismissed` and pushes that carry it go through. Dismissing a secret needs the Admin role on the repository; a dependency needs the Write role. A token needs `repo:admin` for either, and a g1t agent’s token never dismisses alerts." | |
| 4178 | + | }, | |
| 4179 | + | "reopen_security_alert": { | |
| 4180 | + | "params": { | |
| 4181 | + | "owner": "flagon-io", | |
| 4182 | + | "name": "hello", | |
| 4183 | + | "id": "sec_01kp4a7b2c3d4e5f6g7h8j9k0m" | |
| 4184 | + | }, | |
| 4185 | + | "response": { | |
| 4186 | + | "kind": "secret", | |
| 4187 | + | "id": "sec_01kp4a7b2c3d4e5f6g7h8j9k0m", | |
| 4188 | + | "state": "open", | |
| 4189 | + | "secret_type": "aws_access_key", | |
| 4190 | + | "label": "an AWS access key", | |
| 4191 | + | "path": "test/fixtures/aws.env", | |
| 4192 | + | "line": 3, | |
| 4193 | + | "commit": "9f2c1e04b7d3a8e6f5c2b1a0d9e8f7c6b5a4d3e2", | |
| 4194 | + | "preview": "AKIA…MPLE", | |
| 4195 | + | "status": "open", | |
| 4196 | + | "source": "history", | |
| 4197 | + | "test_value": "the documented example key", | |
| 4198 | + | "found_by": null, | |
| 4199 | + | "found_at": "2026-10-01T12:00:00.000Z", | |
| 4200 | + | "dismissed_reason": null, | |
| 4201 | + | "dismissed_comment": null, | |
| 4202 | + | "dismissed_by": null, | |
| 4203 | + | "dismissed_at": null | |
| 4204 | + | }, | |
| 4205 | + | "notes": "The alert is `open` again, and a reopened secret stops pushes that carry it. The same roles as dismissing: Admin for a secret, Write for a dependency." | |
| 4057 | 4206 | } | |
| 4058 | 4207 | } |
| 79 | 79 | return through::<access::RepoInvitation>(op, as_is); | |
| 80 | 80 | } | |
| 81 | 81 | Op::ListOutsideCollaborators => return through::<Vec<access::OutsideCollaborator>>(op, as_is), | |
| 82 | + | // Built by the API itself, in `snake_case`. | |
| 83 | + | Op::ListSecurityAlerts => return through::<Vec<crate::alerts::SecurityAlert>>(op, as_is), | |
| 84 | + | Op::DismissSecurityAlert | Op::ReopenSecurityAlert => { | |
| 85 | + | return through::<crate::alerts::SecurityAlert>(op, as_is); | |
| 86 | + | } | |
| 82 | 87 | _ => {} | |
| 83 | 88 | } | |
| 84 | 89 | let sent = as_services_send(example); | |
| 85 | 90 | match op { | |
| 86 | − | Op::CreateWorkspace => through::<g1t_contracts::identity::Workspace>(op, sent), | |
| 91 | + | Op::CreateWorkspace | Op::UpdateWorkspace => through::<g1t_contracts::identity::Workspace>(op, sent), | |
| 87 | 92 | Op::ListRepos => through::<Vec<repos::Repo>>(op, sent), | |
| 88 | 93 | Op::Search => through::<search::SearchResults>(op, sent), | |
| 89 | 94 | Op::GetRepo |
| 58 | 58 | route("GET", "/user/repository_invitations", Op::ListMyRepoInvitations, &[]), | |
| 59 | 59 | route("PATCH", "/user/repository_invitations/:id", Op::AcceptRepoInvitation, &[]), | |
| 60 | 60 | route("DELETE", "/user/repository_invitations/:id", Op::DeclineRepoInvitation, &[]), | |
| 61 | − | route("PATCH", "/workspaces/:workspace", Op::SetBasePermission, &[]), | |
| 61 | + | route("PATCH", "/workspaces/:workspace", Op::UpdateWorkspace, &[]), | |
| 62 | + | route("PUT", "/workspaces/:workspace/base_permission", Op::SetBasePermission, &[]), | |
| 62 | 63 | route( | |
| 63 | 64 | "GET", | |
| 64 | 65 | "/workspaces/:workspace/outside_collaborators", | |
| 65 | 66 | Op::ListOutsideCollaborators, | |
| 66 | 67 | &[], | |
| 67 | 68 | ), | |
| 69 | + | // Security alerts: secrets and vulnerable dependencies. | |
| 70 | + | route( | |
| 71 | + | "GET", | |
| 72 | + | "/repos/:owner/:name/security/alerts", | |
| 73 | + | Op::ListSecurityAlerts, | |
| 74 | + | &[("state", "state"), ("kind", "kind")], | |
| 75 | + | ), | |
| 76 | + | route("POST", "/repos/:owner/:name/security/alerts/:id/dismiss", Op::DismissSecurityAlert, &[]), | |
| 77 | + | route("POST", "/repos/:owner/:name/security/alerts/:id/reopen", Op::ReopenSecurityAlert, &[]), | |
| 68 | 78 | route("GET", "/repos", Op::ListRepos, &[("q", "query")]), | |
| 69 | 79 | route( | |
| 70 | 80 | "GET", |
| 57 | 57 | Tool { | |
| 58 | 58 | name: "repository", | |
| 59 | 59 | title: "Repositories", | |
| 60 | − | description: "Repositories: find, read and create them, and change their settings. Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.", | |
| 60 | + | description: "Repositories: find, read and create them, change their settings, and see and dismiss their security alerts (secrets and vulnerable dependencies). Name one as \"owner/name\". Deleting, transferring and changing visibility need `confirm`.", | |
| 61 | 61 | default_action: None, | |
| 62 | 62 | actions: &[ | |
| 63 | 63 | a("list", Op::ListRepos, "Repositories you can see"), | |
| 79 | 79 | a("list_deleted", Op::ListDeletedRepos, "A workspace's deleted repositories"), | |
| 80 | 80 | a("restore", Op::RestoreRepo, "Restore a deleted one"), | |
| 81 | 81 | a("purge", Op::PurgeRepo, "Remove a deleted one for good"), | |
| 82 | + | a("security_alerts", Op::ListSecurityAlerts, "Secret and dependency alerts, filtered by state"), | |
| 83 | + | a("dismiss_alert", Op::DismissSecurityAlert, "Dismiss an alert with a reason"), | |
| 84 | + | a("reopen_alert", Op::ReopenSecurityAlert, "Reopen a dismissed alert"), | |
| 82 | 85 | ], | |
| 83 | 86 | }, | |
| 84 | 87 | Tool { | |
| 224 | 227 | Tool { | |
| 225 | 228 | name: "workspace", | |
| 226 | 229 | title: "Workspaces", | |
| 227 | − | description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create or delete one, invite members, and connect integrations and model providers.", | |
| 230 | + | description: "Workspaces own repositories (g1t.sh/{workspace}/{repo}): create, update or delete one, invite members, and connect integrations and model providers.", | |
| 228 | 231 | default_action: None, | |
| 229 | 232 | actions: &[ | |
| 230 | 233 | a("create", Op::CreateWorkspace, "Create a workspace"), | |
| 231 | 234 | a("delete", Op::DeleteWorkspace, "Delete an empty workspace"), | |
| 235 | + | a("update", Op::UpdateWorkspace, "Change its name, description or base permission"), | |
| 232 | 236 | a("list_invites", Op::ListWorkspaceInvites, "Its invites"), | |
| 233 | 237 | a("invite_member", Op::InviteMember, "Invite an email address"), | |
| 234 | 238 | a("revoke_invite", Op::RevokeWorkspaceInvite, "Revoke a pending invite"), | |
| 267 | 271 | matches!( | |
| 268 | 272 | op, | |
| 269 | 273 | Op::DeleteWorkspace | |
| 274 | + | | Op::UpdateWorkspace | |
| 270 | 275 | | Op::DeleteRepo | |
| 271 | 276 | | Op::PurgeRepo | |
| 272 | 277 | | Op::TransferRepo |
| 27 | 27 | { "binding": "DEPLOYMENTS", "service": "g1t-deployments" }, | |
| 28 | 28 | // The context hub: search_context and get_entity. | |
| 29 | 29 | { "binding": "CONTEXT", "service": "g1t-context" }, | |
| 30 | − | { "binding": "SEARCH", "service": "g1t-search" } | |
| 30 | + | { "binding": "SEARCH", "service": "g1t-search" }, | |
| 31 | + | // Secret and dependency alerts: list_security_alerts and dismissing them. | |
| 32 | + | { "binding": "SECURITY", "service": "g1t-security" } | |
| 31 | 33 | ], | |
| 32 | 34 | // GitHub Actions artifacts, in chunks, with KV's own expiry (and cache | |
| 33 | 35 | // entries saved before the cache moved to R2, until they expire). |
| 37 | 37 | ### Repositories up to 1 GB, files up to 32 MB, no LFS | |
| 38 | 38 | ||
| 39 | 39 | A repository can hold up to 1 GB and a single file up to 32 MB. Git LFS is | |
| 40 | − | not supported. | |
| 40 | + | not supported. A push that would cross either is declined before it is | |
| 41 | + | stored, and git prints why. See [Size limits](/guides/git/#size-limits). | |
| 41 | 42 | ||
| 42 | 43 | - **Why.** These are the limits of Cloudflare Artifacts, where every | |
| 43 | − | repository is stored. g1t does not check them before a push reaches the | |
| 44 | − | store yet, so a push that crosses them fails late, and git's message may | |
| 45 | − | not say why. | |
| 44 | + | repository is stored. | |
| 46 | 45 | - **Instead.** Keep large binaries out of the repository: in a release | |
| 47 | 46 | bucket, a package registry or object storage, fetched at build time. | |
| 48 | − | - **Status.** Checking both limits before the push, with a message git | |
| 49 | − | shows you, is planned. Large file storage is planned. Raising the limits | |
| 50 | − | themselves depends on Cloudflare. | |
| 47 | + | - **Status.** Large file storage is planned. Raising the limits themselves | |
| 48 | + | depends on Cloudflare. | |
| 51 | 49 | ||
| 52 | 50 | ### Pushes up to 100 MB each | |
| 53 | 51 | ||
| 98 | 96 | - **Status.** Not scheduled for your own scripts. A hook in the store, | |
| 99 | 97 | which would let g1t enforce more before refs move, depends on Cloudflare. | |
| 100 | 98 | ||
| 101 | − | ### Push protection skips very large pushes | |
| 99 | + | ### Very large pushes are scanned after they land, not before | |
| 102 | 100 | ||
| 103 | − | Very large pushes, by size or by number of commits, are not yet fully | |
| 104 | − | scanned for secrets. Most pushes are scanned in full; we are raising the | |
| 105 | − | limit by streaming the scan instead of reading the whole push at once. | |
| 101 | + | A very large push is too large for push protection to read before it is | |
| 102 | + | stored, so it is let through, and g1t scans every commit it added | |
| 103 | + | afterwards, in the background. A secret found that way is an open alert | |
| 104 | + | rather than a refused push, and the workspace's owners are emailed when one | |
| 105 | + | looks real. Most pushes are scanned before they land. | |
| 106 | 106 | ||
| 107 | 107 | - **Why.** Scanning reads the whole push inside a Worker, which has 128 MB | |
| 108 | − | for everything it is doing at once. Past that size, scanning could fail | |
| 109 | − | the push outright. | |
| 110 | − | - **Instead.** Push large histories in steps (see above), so each push is | |
| 111 | − | scanned. Secrets already in history are listed under | |
| 108 | + | for everything it is doing at once. Past a certain size, scanning could fail | |
| 109 | + | the push outright, and refusing such pushes would block importing real | |
| 110 | + | repositories. | |
| 111 | + | - **Instead.** To have a large history checked before it lands, push it in | |
| 112 | + | steps (see above), so each push is scanned first. Secrets already in | |
| 113 | + | history are listed under | |
| 112 | 114 | [Secrets in history](/guides/security/#secrets-in-history). | |
| 113 | 115 | - **Status.** Planned: scanning while the push streams, at any size. | |
| 114 | 116 | ||
| 140 | 142 | in your workspace; pushing creates it. | |
| 141 | 143 | - **Status.** Not scheduled. | |
| 142 | 144 | ||
| 143 | − | ### Pull request forks are kept after they close | |
| 145 | + | ### Pull request forks are removed a week after they close | |
| 144 | 146 | ||
| 145 | − | A pull request's fork stays after the pull request merges or closes. | |
| 147 | + | Seven days after a pull request merges or closes, its fork's git data is | |
| 148 | + | removed. The pull request's changes stay readable: its head is kept in the | |
| 149 | + | repository as `refs/pull/<pull request id>/head`. Pushing to the fork, or | |
| 150 | + | reopening the pull request, makes the fork again from there. | |
| 146 | 151 | ||
| 147 | 152 | - **Why.** Cloudflare has not documented whether a fork shares stored | |
| 148 | − | objects with its source or copies them, and the answer decides how and | |
| 149 | − | when forks should be cleaned up. | |
| 150 | − | - **Instead.** Nothing you need to do. | |
| 151 | − | - **Status.** Deleting forks some time after their pull request closes is | |
| 152 | − | planned. Clear fork storage rules depend on Cloudflare. | |
| 153 | + | objects with its source or copies them, so forks are not kept longer | |
| 154 | + | than they are useful. | |
| 155 | + | - **Instead.** Nothing you need to do. To keep working on a closed pull | |
| 156 | + | request's change, fetch `refs/pull/<pull request id>/head` and push it | |
| 157 | + | to a branch. | |
| 158 | + | - **Status.** Clear fork storage rules depend on Cloudflare. | |
| 153 | 159 | ||
| 154 | 160 | ### No conflict resolution in the browser | |
| 155 | 161 | ||
| 163 | 169 | ||
| 164 | 170 | ### No Docker in g1t's sandboxes | |
| 165 | 171 | ||
| 166 | − | On g1t's own machines, Docker container actions, `services:` containers and | |
| 167 | − | `container:` do not run, and a step cannot run `docker build`. | |
| 172 | + | On g1t's own machines, a job's `container:` image is not used (its steps | |
| 173 | + | run on g1t's runner image instead), and a step cannot run `docker build`. | |
| 174 | + | Docker container actions (`uses: docker://…`, or an action that runs as a | |
| 175 | + | Docker image) and `services:` containers, such as a database, do not run | |
| 176 | + | on any runner yet, self-hosted ones included. | |
| 168 | 177 | ||
| 169 | 178 | - **Why.** Jobs run in Cloudflare Containers, which offer no supported way | |
| 170 | 179 | to run Docker or another image builder inside a container. | |
| 171 | − | - **Instead.** Run those jobs on a [self-hosted runner](/guides/self-hosted-runners/). | |
| 172 | − | A runner in Docker mode runs each job in its `container:` image. To build | |
| 173 | − | images, register a runner with `--no-docker` on a machine that has Docker, | |
| 174 | − | and its steps can call `docker build` and `docker push`. Self-hosted time | |
| 175 | − | costs nothing. | |
| 176 | − | - **Status.** Image builds on g1t's machines depend on Cloudflare. | |
| 180 | + | - **Instead.** Run `container:` jobs and image builds on a | |
| 181 | + | [self-hosted runner](/guides/self-hosted-runners/). A runner in Docker | |
| 182 | + | mode runs each job in its `container:` image. To build images, register a | |
| 183 | + | runner with `--no-docker` on a machine that has Docker, and its steps can | |
| 184 | + | call `docker build` and `docker push`. Self-hosted time costs nothing. For | |
| 185 | + | a database, start it from a `run:` step on a self-hosted runner. | |
| 186 | + | - **Status.** Docker container actions and `services:` are planned. Image | |
| 187 | + | builds on g1t's machines depend on Cloudflare. | |
| 177 | 188 | ||
| 178 | 189 | ### Linux only on g1t's machines | |
| 179 | 190 | ||
| 190 | 201 | | --- | --- | | |
| 191 | 202 | | Largest machine | 4 vCPUs, 12 GiB of memory, 20 GB of disk (`g1t-4core`). No GPUs. | | |
| 192 | 203 | | One job on g1t's machines | 60 minutes. On a self-hosted runner, 24 hours. | | |
| 193 | − | | One cache entry | 2 GB, compressed. A larger one is not saved. | | |
| 194 | − | | A repository's caches | 10 GB together. Past it, the entries restored longest ago are removed. | | |
| 204 | + | | One cache entry | 2 GiB, compressed. A larger one is not saved. | | |
| 205 | + | | A repository's caches | 10 GiB together. Past it, the entries restored longest ago are removed. | | |
| 195 | 206 | | One artifact | 60 MB, kept for 14 days | | |
| 196 | 207 | ||
| 197 | 208 | The machine sizes are Cloudflare Containers' instance sizes. For more, use a | |
| 233 | 244 | - **Why.** Each of these is a resource g1t has to create and bill per | |
| 234 | 245 | project, and that is not built yet. | |
| 235 | 246 | - **Instead.** Check that a binding exists before using it. The deployment | |
| 236 | − | lists each one it left out. | |
| 247 | + | lists each binding it left out, and warns when its cron triggers will | |
| 248 | + | not run. | |
| 237 | 249 | - **Status.** Planned. See [Workers projects](/guides/deployments/#workers-projects). | |
| 238 | 250 | ||
| 239 | 251 | ### Build and size limits | |
| 275 | 287 | ||
| 276 | 288 | ### Payments are in test mode | |
| 277 | 289 | ||
| 278 | − | While payments are in test mode, no real card is charged, and g1t's hosted | |
| 279 | − | models are open only to g1t's own workspaces. | |
| 290 | + | While payments are in test mode, no real card is charged, so a card check | |
| 291 | + | proves nothing. g1t's hosted models are open only to a few invited | |
| 292 | + | workspaces, g1t's own among them. Every other workspace, trial or not, | |
| 293 | + | runs its agents on its own model provider; without one, assigning an agent | |
| 294 | + | is refused with a message that says so. | |
| 280 | 295 | ||
| 281 | 296 | - **Instead.** Connect your own [model provider](/guides/models/). Your | |
| 282 | 297 | agents then run on your keys, and the provider bills you directly. |
| 7 | 7 | ||
| 8 | 8 | We're the small team at Flagon, Inc. building g1t, a git platform where | |
| 9 | 9 | people and coding agents work in the same issues, pull requests and merge | |
| 10 | − | queue. Every part of it runs on you: about twenty Workers, a D1 database per | |
| 11 | − | service, Artifacts for every repository and every pull request, Containers | |
| 12 | − | for agents and CI, R2, KV, Queues, and Cloudflare for SaaS for our customers' | |
| 13 | − | domains. We have no servers. | |
| 10 | + | queue. Every part of it runs on you: about twenty Workers, a D1 database for | |
| 11 | + | each service that keeps data, Artifacts for every repository and every pull | |
| 12 | + | request's fork, Containers for agents and CI, R2, KV, Queues, and Cloudflare | |
| 13 | + | for SaaS for our customers' domains. We have no servers. | |
| 14 | 14 | ||
| 15 | 15 | This is a thank-you, and a list of what would help us most next. | |
| 16 | 16 | ||
| 32 | 32 | every page we render, blame, mergeability and search, without a git client | |
| 33 | 33 | anywhere. | |
| 34 | 34 | ||
| 35 | − | The rest of the platform held up too. Rust compiled to WebAssembly runs our | |
| 36 | − | services. D1's read replication is free and good. Containers gave us | |
| 35 | + | The rest of the platform held up too. Rust compiled to WebAssembly runs most of | |
| 36 | + | our services, and TypeScript the rest. D1's read replication is free and good. Containers gave us | |
| 37 | 37 | sandboxes in three sizes. With a cached credential and ref listing, a | |
| 38 | 38 | `git fetch` with nothing new answers in under half a second, and most of | |
| 39 | 39 | our pages answer in under 250 ms. We went from an empty repository to a | |
| 56 | 56 | **What a fork stores.** Forks are the natural primitive for a pull request, | |
| 57 | 57 | and agents open pull requests by the thousand. We can't find whether a fork | |
| 58 | 58 | shares objects with its source or copies them. If it copies, an agent-heavy | |
| 59 | − | account reaches the 1 TB account limit in days, and at that point every push | |
| 59 | + | account reaches the 1 TB account limit in days (it can be raised on | |
| 60 | + | request, but only by asking), and at that point every push | |
| 60 | 61 | in the account fails, for every customer at once. We keep forks for now, | |
| 61 | 62 | and are measuring it ourselves. | |
| 62 | 63 | ||
| 65 | 66 | git's wire protocol to our own storage from inside a Worker, buffering packs | |
| 66 | 67 | in an isolate with 128 MB to share. With no hook before refs move, branch | |
| 67 | 68 | protection and secret scanning only hold for pushes through our proxy, which | |
| 68 | − | parses every pack in WebAssembly before forwarding it. We wrote a second | |
| 69 | + | parses each pack in WebAssembly before forwarding it, up to the size an | |
| 70 | + | isolate can hold. We wrote a second | |
| 69 | 71 | implementation of git's pack format to get there. | |
| 70 | 72 | ||
| 71 | 73 | **Ref-change events and the cost of a credential.** Push events need one | |
| 82 | 84 | measure each Worker by hand. | |
| 83 | 85 | ||
| 84 | 86 | **D1 sessions across service bindings.** Read replicas need a bookmark to | |
| 85 | − | give read-your-writes. Our site calls seven services, each with its own | |
| 86 | − | database, so we built a header protocol to carry bookmarks through service | |
| 87 | + | give read-your-writes. Our site reads from seven services, each with its | |
| 88 | + | own database, so we built a header protocol to carry bookmarks through service | |
| 87 | 89 | bindings into a cookie and back. | |
| 88 | 90 | ||
| 89 | 91 | **Containers that build images and keep disks.** We found no supported way | |
| 99 | 101 | ## What we built in the meantime | |
| 100 | 102 | ||
| 101 | 103 | A per-workspace operation counter that is our best guess at your invoice. A | |
| 102 | − | fork sweep we can switch on once we know what forks cost. A smart HTTP | |
| 104 | + | smart HTTP | |
| 103 | 105 | client inside a Worker for landing, catch-up, mirrors and imports. Our own | |
| 104 | 106 | push policy in front of Artifacts. A versioned ref cache with a test that | |
| 105 | 107 | guards it. Two layers of credential caching. A bookmark protocol for D1. | |
| 107 | 109 | `Server-Timing` header on every response so we see the next regression. | |
| 108 | 110 | Image builds on a laptop. | |
| 109 | 111 | ||
| 110 | − | All of it works. Most of it is code we'd happily delete. | |
| 112 | + | All of it works. Most of it is code we'd happily delete. Next is a fork | |
| 113 | + | sweep, to switch on once we know what forks cost. | |
| 111 | 114 | ||
| 112 | 115 | ## What we're asking for | |
| 113 | 116 |
| 3 | 3 | description: Why a pull request on g1t gets its own fork, when a branch is the better choice, and what each costs. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | − | On most forges, a pull request comes from a branch of the repository. g1t | |
| 7 | − | has those too. But an agent's pull request comes from a **fork**: a separate | |
| 6 | + | A pull request can come from a branch of the repository. But an agent's | |
| 7 | + | pull request comes from a **fork**: a separate | |
| 8 | 8 | repository that starts as a copy of yours. This page explains why, what it | |
| 9 | 9 | costs, and when to use which. | |
| 10 | 10 | ||
| 38 | 38 | listed to every client on every fetch, and each needing cleanup once the | |
| 39 | 39 | work is merged or closed. | |
| 40 | 40 | ||
| 41 | − | Forks add nothing to the repository. A closed pull request is a fork that is | |
| 42 | − | never looked at again. The repository's own refs stay the handful that | |
| 43 | − | describe the project. | |
| 41 | + | Forks add nothing to the repository's branches. A week after a pull request | |
| 42 | + | merges or closes, its fork is removed, and only its head is kept in the | |
| 43 | + | repository, as `refs/pull/<pull request id>/head`, which clones and fetches | |
| 44 | + | do not download. The repository's branches stay the handful that describe | |
| 45 | + | the project. | |
| 44 | 46 | ||
| 45 | 47 | ### Each pull request has its own capacity | |
| 46 | 48 |
| 184 | 184 | request's head commit, or, in a repository that merges through | |
| 185 | 185 | [the merge queue](/guides/merge-queue/), adds it to the queue. | |
| 186 | 186 | ||
| 187 | − | A pull request can only merge if it contains everything already on `main`. | |
| 188 | − | If something else landed first, merging is refused and the pull request is | |
| 189 | − | **behind**. Its page says so before you try. | |
| 187 | + | `main` only moves forward to a commit that contains everything already on | |
| 188 | + | it. If something else landed first, the pull request is **behind**, and its | |
| 189 | + | page says so. Merging it then brings it up to date first and lands it once | |
| 190 | + | that is done, unless the repository requires pull requests to be up to date | |
| 191 | + | before they merge; then merging is refused until it has caught up. | |
| 190 | 192 | ||
| 191 | − | **Catch up with main** fixes that. When the pull request and `main` changed | |
| 193 | + | **Catch up with main** brings it up to date. When the pull request and `main` changed | |
| 192 | 194 | different files, g1t merges `main` in itself and pushes the merge in a few | |
| 193 | 195 | seconds. When they changed some of the same files, a g1t agent merges `main` | |
| 194 | 196 | into the pull request in a sandbox: if the merge is clean, it is pushed as it | |
| 231 | 233 | [What g1t can't do yet](/about/limitations/)): | |
| 232 | 234 | ||
| 233 | 235 | - **Milestones.** | |
| 234 | − | - **g1t agents for everyone.** g1t can put its own agents on an issue, each | |
| 235 | − | in a sandbox. A workspace that connects its own model provider can use | |
| 236 | − | them today on the [g1t plan](/guides/usage-and-billing/#the-g1t-plan) | |
| 237 | − | or the one-time $5 trial after a card check. | |
| 238 | − | See [the trial](/guides/usage-and-billing/#the-trial). | |
| 236 | + | - **g1t's hosted models for everyone.** g1t can put its own agents on an | |
| 237 | + | issue, each in a sandbox, on the | |
| 238 | + | [g1t plan](/guides/usage-and-billing/#the-g1t-plan) or the one-time $5 | |
| 239 | + | [trial](/guides/usage-and-billing/#the-trial) after a card check. Agents | |
| 240 | + | run on the workspace's own model provider, or on g1t's hosted models | |
| 241 | + | while its trial has credit; hosted models open to every workspace once | |
| 242 | + | payments go live. See [model providers](/guides/models/). |
| 219 | 219 | ||
| 220 | 220 | ## Through the API | |
| 221 | 221 | ||
| 222 | − | Every route is in the [API reference](/reference/api/). Each has an MCP | |
| 223 | − | tool of the same name. | |
| 222 | + | Every route is in the [API reference](/reference/api/). Each is also an | |
| 223 | + | action of an [MCP tool](/reference/mcp/): `access` for a repository's | |
| 224 | + | people and the base permission, `account` for invitations to you. | |
| 224 | 225 | ||
| 225 | − | | Route | MCP tool | What it does | Who | | |
| 226 | + | | Route | MCP tool and action | What it does | Who | | |
| 226 | 227 | | --- | --- | --- | --- | | |
| 227 | − | | `GET /repos/{owner}/{name}/collaborators` | `list_collaborators` | Everyone with access to a repository, their role and where it comes from. | Write | | |
| 228 | − | | `GET /repos/{owner}/{name}/collaborators/{username}/permission` | `get_collaborator_permission` | One person's role on a repository and what it lets them do. | Write, or about yourself | | |
| 229 | − | | `POST /repos/{owner}/{name}/collaborators` | `add_collaborator` | Give someone a role. Body: `invitee` (a username or an email address) and `role`. Answers with `result`: `granted` or `invited`. | Admin | | |
| 230 | − | | `PATCH /repos/{owner}/{name}/collaborators/{username}` | `update_collaborator` | Change someone's role, or a pending invitation's. Body: `role`. | Admin | | |
| 231 | − | | `DELETE /repos/{owner}/{name}/collaborators/{username}` | `remove_collaborator` | Take away the role given to someone on the repository. | Admin, or yourself | | |
| 232 | − | | `GET /repos/{owner}/{name}/invitations` | `list_repo_invitations` | A repository's pending invitations. | Admin | | |
| 233 | − | | `DELETE /repos/{owner}/{name}/invitations/{id}` | `revoke_repo_invitation` | Withdraw a pending invitation. | Admin | | |
| 234 | − | | `GET /user/repository_invitations` | `list_my_repo_invitations` | The invitations waiting for you. | You | | |
| 235 | − | | `PATCH /user/repository_invitations/{id}` | `accept_repo_invitation` | Accept one. | You | | |
| 236 | − | | `DELETE /user/repository_invitations/{id}` | `decline_repo_invitation` | Decline one. | You | | |
| 237 | − | | `PATCH /workspaces/{workspace}` | `set_base_permission` | Set the base permission. Body: `base_permission`: `none`, `read`, `write` or `admin`. | Owners | | |
| 238 | − | | `GET /workspaces/{workspace}/outside_collaborators` | `list_outside_collaborators` | A workspace's outside collaborators and the repositories each can reach. | Owners | | |
| 228 | + | | `GET /repos/{owner}/{name}/collaborators` | `access` `list_collaborators` | Everyone with access to a repository, their role and where it comes from. | Write | | |
| 229 | + | | `GET /repos/{owner}/{name}/collaborators/{username}/permission` | `access` `get_permission` | One person's role on a repository and what it lets them do. | Write, or about yourself | | |
| 230 | + | | `POST /repos/{owner}/{name}/collaborators` | `access` `add_collaborator` | Give someone a role. Body: `invitee` (a username or an email address) and `role`. Answers with `result`: `granted` or `invited`. | Admin | | |
| 231 | + | | `PATCH /repos/{owner}/{name}/collaborators/{username}` | `access` `update_collaborator` | Change someone's role, or a pending invitation's. Body: `role`. | Admin | | |
| 232 | + | | `DELETE /repos/{owner}/{name}/collaborators/{username}` | `access` `remove_collaborator` | Take away the role given to someone on the repository. | Admin, or yourself | | |
| 233 | + | | `GET /repos/{owner}/{name}/invitations` | `access` `list_invitations` | A repository's pending invitations. | Admin | | |
| 234 | + | | `DELETE /repos/{owner}/{name}/invitations/{id}` | `access` `revoke_invitation` | Withdraw a pending invitation. | Admin | | |
| 235 | + | | `GET /user/repository_invitations` | `account` `list_repository_invitations` | The invitations waiting for you. | You | | |
| 236 | + | | `PATCH /user/repository_invitations/{id}` | `account` `accept_repository_invitation` | Accept one. | You | | |
| 237 | + | | `DELETE /user/repository_invitations/{id}` | `account` `decline_repository_invitation` | Decline one. | You | | |
| 238 | + | | `PUT /workspaces/{workspace}/base_permission` | `access` `set_base_permission` | Set the base permission. Body: `base_permission`: `none`, `read`, `write` or `admin`. `PATCH /workspaces/{workspace}` (`workspace` `update`) takes `base_permission` too, with the `access:admin` scope. | Owners | | |
| 239 | + | | `GET /workspaces/{workspace}/outside_collaborators` | `access` `list_outside_collaborators` | A workspace's outside collaborators and the repositories each can reach. | Owners | | |
| 239 | 240 | ||
| 240 | 241 | Changing who has access, answering an invitation and setting the base | |
| 241 | 242 | permission are for people, signed in or with a personal access token. |
| 44 | 44 | | `secrets.*`, `vars.*`, `secrets.GITHUB_TOKEN` | The same. `secrets.G1T_TOKEN` is the workspace's own token for the run; `GITHUB_TOKEN` is its alias. | | |
| 45 | 45 | | `environment:` on a job | The job reads each key's row for that environment, as GitHub's environment secrets work. | | |
| 46 | 46 | | `actions/upload-artifact`, `actions/download-artifact` | Kept with the run for 14 days, passed between its jobs, and downloadable from the run's page. Up to 60 MB each. | | |
| 47 | − | | `actions/cache`, `actions/cache/restore`, `actions/cache/save` | Kept per repository, found by `key` or the newest under a `restore-keys` prefix. `path` takes globs and `!` exclusions. Up to 2 GB each; see [the cache](#the-cache). | | |
| 47 | + | | `actions/cache`, `actions/cache/restore`, `actions/cache/save` | Kept per repository, found by `key` or the newest under a `restore-keys` prefix. `path` takes globs and `!` exclusions. Up to 2 GiB each; see [the cache](#the-cache). | | |
| 48 | 48 | ||
| 49 | 49 | The **Actions** page of a workflow says, under *How this runs on g1t*, | |
| 50 | 50 | anything in it that runs differently. | |
| 55 | 55 | job with `runs-on: windows-latest` or `macos-latest` fails, and says so. | |
| 56 | 56 | [Self-hosted runners](/guides/self-hosted-runners/) of any OS run them: | |
| 57 | 57 | `runs-on: [self-hosted, windows]`. | |
| 58 | − | - **Docker** container actions, `services:` containers and `container:`. | |
| 58 | + | - **Docker** container actions, `services:` containers and `container:` on | |
| 59 | + | g1t's machines. A job's `container:` is ignored there and its steps run on | |
| 60 | + | g1t's image; a [self-hosted runner](/guides/self-hosted-runners/#what-a-job-gets) | |
| 61 | + | that runs jobs in Docker uses it. | |
| 59 | 62 | - **Reusable workflows from other repositories** (`uses: owner/repo/.github/workflows/x.yml@v1`); ones in the same repository work. | |
| 60 | 63 | - **The toolkit's own cache.** Actions that cache through GitHub's service | |
| 61 | 64 | themselves, such as `actions/setup-node` with `cache: npm`, run without | |
| 73 | 76 | Jobs run in a fresh sandbox each: Debian with Node 24, Python 3, Go, Rust, | |
| 74 | 77 | `build-essential`, `git`, `curl`, `jq` and passwordless `sudo`, in GitHub's | |
| 75 | 78 | layout (`/home/runner/work`, `RUNNER_TEMP`, `RUNNER_TOOL_CACHE`). | |
| 76 | − | `runner.os` is `Linux`. `ubuntu-latest`, `ubuntu-24.04`, `self-hosted` and | |
| 77 | − | other Linux labels all run here. Setup actions such as | |
| 79 | + | `runner.os` is `Linux`. `ubuntu-latest`, `ubuntu-24.04` and other Linux | |
| 80 | + | labels all run here. A job whose `runs-on` names `self-hosted` waits for one | |
| 81 | + | of your [self-hosted runners](/guides/self-hosted-runners/) instead. Setup actions such as | |
| 78 | 82 | `actions/setup-node` and `actions/setup-python` install other versions as | |
| 79 | 83 | they do on GitHub. | |
| 80 | 84 | ||
| 102 | 106 | Builds that compile, such as Rust or a large TypeScript project, finish | |
| 103 | 107 | several times faster on one. | |
| 104 | 108 | ||
| 105 | − | A job runs for at most 60 minutes, whatever its `timeout-minutes`, and | |
| 106 | − | for less if the workspace's plan caps runs lower (a new workspace's first | |
| 107 | − | month, or the trial). A job stopped at its time cap fails saying so. | |
| 109 | + | A job on g1t's machines runs for at most 60 minutes, whatever its | |
| 110 | + | `timeout-minutes`; one on a self-hosted runner can run for up to 24 hours. | |
| 111 | + | A job stopped at its time cap fails saying so. | |
| 108 | 112 | ||
| 109 | 113 | ### What a job can reach | |
| 110 | 114 | ||
| 139 | 143 | ||
| 140 | 144 | | | | | |
| 141 | 145 | | --- | --- | | |
| 142 | − | | One entry | Up to 2 GB, compressed. A larger one is not saved, and the job goes on. | | |
| 143 | − | | A repository's entries | Up to 10 GB together. Saving past it removes the entries restored longest ago. | | |
| 146 | + | | One entry | Up to 2 GiB, compressed. A larger one is not saved, and the job goes on. | | |
| 147 | + | | A repository's entries | Up to 10 GiB together. Saving past it removes the entries restored longest ago. | | |
| 144 | 148 | | How long | Until it has not been restored for 7 days, and at most 28 days after it was saved. | | |
| 145 | 149 | | Keys | Written once: saving under a key that exists does nothing. A restore finds its `key` exactly, else the newest entry whose key starts with one of its `restore-keys`. | | |
| 146 | 150 | | `path` | Files and folders; globs, `**` included; `~/` is the home folder; a line starting with `!` leaves matching paths out. | | |
| 233 | 237 | on `pull_request`, on `push` to the default branch, and on `merge_group`. | |
| 234 | 238 | 3. Change it on the pull request if the steps are not how your project | |
| 235 | 239 | builds, and merge it. | |
| 236 | − | 4. Once it has run, `CI` is offered under **Required status checks**. | |
| 240 | + | 4. Once it has run, `CI` is offered under **Require status checks to pass | |
| 241 | + | before merging**. | |
| 237 | 242 | Require it, so that nothing merges into the default branch unless it | |
| 238 | 243 | passes. | |
| 239 | 244 | ||
| 269 | 274 | ||
| 270 | 275 | Jobs run in g1t's sandboxes, so they need the | |
| 271 | 276 | [g1t plan](/guides/usage-and-billing/#the-g1t-plan) or | |
| 272 | − | [the trial](/guides/usage-and-billing/#the-trial). On a public repository, | |
| 277 | + | [the trial](/guides/usage-and-billing/#the-trial); jobs on | |
| 278 | + | [self-hosted runners](/guides/self-hosted-runners/#billing) need neither. | |
| 279 | + | On a public repository, | |
| 273 | 280 | [g1t's open-source pool](/guides/usage-and-billing/#the-open-source-pool) | |
| 274 | 281 | runs them too, after a card check, until the month's pool is spent. | |
| 275 | 282 | ||
| 279 | 286 | [sandbox time](/guides/usage-and-billing/#sandbox-time), from the first | |
| 280 | 287 | second. A job billing refuses does not start: it is recorded as failed | |
| 281 | 288 | with "Not started:" and the reason, such as "Workflows run in g1t's | |
| 282 | − | sandboxes, which need a paid workspace", and what to do about it. The | |
| 289 | + | sandboxes, which cost real money, so they need the g1t plan ($20 a month) | |
| 290 | + | or a card check", and what to do about it. The | |
| 283 | 291 | Actions page tells people with Write on a repository whose workspace | |
| 284 | 292 | cannot run jobs before the first run. | |
| 285 | 293 | ||
| 298 | 306 | | `cancel` | `POST /repos/{owner}/{repo}/actions/runs/{id}/cancel` | | |
| 299 | 307 | | `rerun` | `POST …/runs/{id}/rerun`, or `…/rerun-failed-jobs` | | |
| 300 | 308 | | `update` | `PUT …/workflows/{workflow}/enable` and `…/disable` | | |
| 301 | − | | `list_actions_secrets`, `set_actions_secret`, `delete_actions_secret` | `GET`, `PUT` and `DELETE /repos/{owner}/{repo}/actions/secrets/{name}` | | |
| 302 | − | | `list_actions_variables`, `set_actions_variable`, `delete_actions_variable` | `GET` and `POST /repos/{owner}/{repo}/actions/variables`, `PATCH` and `DELETE …/variables/{name}` | | |
| 303 | 309 | ||
| 310 | + | Secrets and variables have a tool of their own, `secret`: | |
| 311 | + | ||
| 312 | + | | `secret` action | Route | | |
| 313 | + | | --- | --- | | |
| 314 | + | | `list_secrets`, `set_secret`, `delete_secret` | `GET /repos/{owner}/{repo}/actions/secrets`, `PUT` and `DELETE …/secrets/{name}` | | |
| 315 | + | | `list_variables`, `set_variable`, `delete_variable` | `GET` and `POST /repos/{owner}/{repo}/actions/variables`, `PATCH` and `DELETE …/variables/{name}` | | |
| 316 | + | ||
| 304 | 317 | Workspace secrets and variables are under | |
| 305 | 318 | `/workspaces/{workspace}/actions/secrets` and `…/variables`. The fields | |
| 306 | 319 | g1t adds (environments, who reads a row, linked repositories) are in |
| 29 | 29 | | `workspace.base_permission_changed` | An owner changed what members get on every repository. | | |
| 30 | 30 | ||
| 31 | 31 | Through the API and the MCP server, the call itself is recorded under its | |
| 32 | − | tool's name too, such as `delete_repo`. See | |
| 32 | + | operation's name too, such as `delete_repo`. See | |
| 33 | 33 | [managing a repository](/guides/managing-repositories/). | |
| 34 | 34 | ||
| 35 | 35 | Refusals are recorded too, with the rule that refused them. Entries are |
| 332 | 332 | ||
| 333 | 333 | | Scope | What it lets a token do | | |
| 334 | 334 | | --- | --- | | |
| 335 | − | | `repo:read` | See repositories, their settings, labels and timelines, and search | | |
| 335 | + | | `repo:read` | See repositories, their settings, labels, timelines and security alerts, and search | | |
| 336 | 336 | | `repo:write` | Create repositories, rename branches and change how pull requests merge | | |
| 337 | − | | `repo:admin` | Rename, archive, transfer, delete or change who can see a repository | | |
| 337 | + | | `repo:admin` | Rename, archive, transfer, delete or change who can see a repository, and dismiss security alerts | | |
| 338 | 338 | | `code:read` | Clone and fetch private repositories with git | | |
| 339 | 339 | | `code:write` | Push commits with git | | |
| 340 | 340 | | `issues:read` | Read issues, comments and plans | | |
| 394 | 394 | | Preset | Scopes | | |
| 395 | 395 | | --- | --- | | |
| 396 | 396 | | Read only | Every `read` scope. Changes nothing. | | |
| 397 | − | | Agent | Every `read` scope, and `code:write`, `issues:write`, `pull_requests:write`, `agents:run` and `memory:write`. Reads everything, works on issues and pull requests, pushes code and runs g1t agents. No admin scope. | | |
| 397 | + | | Agent | Every `read` scope except `runners:read`, and `code:write`, `issues:write`, `pull_requests:write`, `agents:run` and `memory:write`. Reads everything, works on issues and pull requests, pushes code and runs g1t agents. No admin scope. | | |
| 398 | 398 | | CI | `repo:read`, `code:read`, `code:write`, `workflows:read` and `workflows:write`. Clones and pushes code, and runs workflows. | | |
| 399 | 399 | | Full access | Everything you can do, including deleting repositories and changing who has access. Marked **Dangerous**. | | |
| 400 | 400 | ||
| 469 | 469 | everything you can. | |
| 470 | 470 | ||
| 471 | 471 | An application that asks for no scopes in particular gets the | |
| 472 | − | [Agent preset](#presets): every `read` scope, and `code:write`, | |
| 472 | + | [Agent preset](#presets): every `read` scope except `runners:read`, and `code:write`, | |
| 473 | 473 | `issues:write`, `pull_requests:write`, `agents:run` and `memory:write`. | |
| 474 | 474 | It never gets an admin scope unless it asks for one and you leave it | |
| 475 | 475 | ticked. |
| 45 | 45 | ||
| 46 | 46 | | Choose | For an agent | | |
| 47 | 47 | | --- | --- | | |
| 48 | − | | Scopes | The **Agent** preset: every `read` scope, and `code:write`, `issues:write`, `pull_requests:write`, `agents:run` and `memory:write`. Untick `agents:run` if it should not start g1t's agents, which spends the workspace's money. | | |
| 48 | + | | Scopes | The **Agent** preset: every `read` scope except `runners:read`, and `code:write`, `issues:write`, `pull_requests:write`, `agents:run` and `memory:write`. Untick `agents:run` if it should not start g1t's agents, which spends the workspace's money. | | |
| 49 | 49 | | Expires | The shortest that fits the work, such as 30 days. | | |
| 50 | 50 | ||
| 51 | 51 | A token reaches every workspace and repository you can. To keep an agent | |
| 151 | 151 | each, compare them, and read each one's check results from `get`. It can | |
| 152 | 152 | leave findings on specific lines with `issue` and `comment`, give a verdict | |
| 153 | 153 | with `pull_request` and `review`, and, if its account is a member of the | |
| 154 | − | workspace, `merge` the best one. It cannot review a pull request it opened. | |
| 154 | + | workspace, `merge` the best one. It cannot approve or request changes on a pull request it opened. | |
| 155 | 155 | ||
| 156 | 156 | ## Filing issues from another system | |
| 157 | 157 |
| 43 | 43 | ## Turn on deployments | |
| 44 | 44 | ||
| 45 | 45 | 1. **Start the g1t plan for the workspace**, if it is not on. An owner | |
| 46 | − | opens **Settings → Billing**, `g1t.sh/<workspace>/-/billing`, and | |
| 47 | − | starts it under **Plans**. Back on Billing, the plan says **On** with | |
| 48 | − | its renewal date. The trial does not pay for deployments. | |
| 46 | + | opens **Settings → Billing and plans**, `g1t.sh/<workspace>/-/billing`, | |
| 47 | + | and chooses **Start the g1t plan** on the **g1t** card. Back on | |
| 48 | + | Billing, the card says **On the g1t plan**. The trial does not pay for | |
| 49 | + | deployments. | |
| 49 | 50 | 2. **Turn on deployments for a project.** Someone with the Admin role on | |
| 50 | 51 | its repository opens the project's | |
| 51 | 52 | **Settings → Deployments**, `g1t.sh/<workspace>/<project>/settings/deployments`, | |
| 95 | 96 | ||
| 96 | 97 | - Your Worker's `fetch` handler runs as written, and its static assets | |
| 97 | 98 | are served under the binding name your config gives them. Cron triggers | |
| 98 | − | in the config are not scheduled. | |
| 99 | + | (`triggers.crons`) are not scheduled, so a `scheduled` handler never | |
| 100 | + | runs; the deployment says so in its warnings. | |
| 99 | 101 | - `vars` are deployed as plain-text bindings (or JSON, for objects). Rows | |
| 100 | 102 | of the project's [secrets and variables](/guides/secrets-and-variables/) | |
| 101 | 103 | available to Deployments are bound too, and replace a `var` of the same | |
| 398 | 400 | network is restricted to the project's allowed domains, the package | |
| 399 | 401 | registries, GitHub and Cloudflare's API, as | |
| 400 | 402 | [workflow jobs'](/guides/actions/#what-a-job-can-reach) are, and it | |
| 401 | − | stops at 30 minutes, or sooner if the workspace's plan caps runs lower. | |
| 403 | + | stops at 30 minutes. | |
| 402 | 404 | When it stops, what it cost is settled against what was reserved. | |
| 403 | 405 | 3. The sandbox builds, then lists its files. g1t opens an upload with | |
| 404 | 406 | Cloudflare for exactly those files and hands the sandbox a key that can | |
| 419 | 421 | ||
| 420 | 422 | | You see | Do | | |
| 421 | 423 | | --- | --- | | |
| 422 | − | | "Deployments is a paid feature, and it is not on" | An owner starts the g1t plan under Billing. | | |
| 423 | − | | "Not started" or "Deployments need a paid workspace" | The build was refused before it started. Follow the link in the message: start the plan, or raise the spend limit, on Billing. | | |
| 424 | + | | "Deployments come with the g1t plan … and `<workspace>` does not have it" | An owner starts the g1t plan under Billing. | | |
| 425 | + | | A deployment **Skipped**, saying it reached its spend limit or that new compute is paused | The build was refused before it started. Follow the link in the message: raise the spend limit, prepay, or answer the spike, on Billing. | | |
| 424 | 426 | | "Stopped: unusual CPU use" | The build looked like it was mining. Contact support if it was a real build; see [abuse and mining](/guides/guardrails/#abuse-and-mining). | | |
| 425 | 427 | | "403" from a host in the build log | The build's network is restricted. Add the host to the project's allowed domains under **Settings → Guardrails**. | | |
| 426 | 428 | | "found nothing to serve" | Add a `build` script, an `index.html`, or a Workers config; or set **Output directory**. | | |
| 427 | 429 | | "the output directory `x` does not exist after the build" | The build wrote elsewhere: check its log, then fix **Output directory**. | | |
| 428 | 430 | | "`d1_databases` is not provisioned on g1t.page yet" | The app deployed without that binding. See [Workers projects](#workers-projects). | | |
| 431 | + | | "`triggers.crons` (1 schedule) is not set up on g1t.page yet" | The app deployed, but its `scheduled` handler never runs. See [Workers projects](#workers-projects). | | |
| 429 | 432 | | A preview page says "This preview is not up" | It came down (see [When apps come down](#when-apps-come-down)). Push, or choose **Redeploy**. | | |
| 430 | 433 | | "Custom domains are being switched on" | Custom domains are not on for g1t.page yet. Domains you add are kept, and set up by themselves once they are. | | |
| 431 | 434 | | A domain stays at **Waiting for DNS** | Check the record against the one listed, remove other `A`/`AAAA` records for the same name, then choose **Check now**. | | |
| 432 | 435 | | A domain says "This domain is not set up" | It points at g1t, but no project has added it. Add it under **Settings → Domains**. | | |
| 433 | − | | "The build did not finish in 45 minutes" | Builds are stopped after 45 minutes. Make the build faster, or build less for previews with **Build command**. | | |
| 436 | + | | "The build did not finish in 45 minutes" | The build stopped reporting: a build stops at 30 minutes, and one not heard from after 45 is failed. Make the build faster, or build less for previews with **Build command**. | | |
| 434 | 437 | ||
| 435 | 438 | ## Running your own g1t | |
| 436 | 439 |
| 360 | 360 | ||
| 361 | 361 | ## Other things g1t agents do | |
| 362 | 362 | ||
| 363 | − | - **Review.** On a pull request that is ready, **Review by a g1t agent** | |
| 363 | + | - **Review.** On a pull request that is ready, **Request review from g1t agent** | |
| 364 | 364 | has an agent read the change and post comments on lines, a summary and a | |
| 365 | 365 | verdict. | |
| 366 | 366 | - **Catch up.** When `main` has moved under a pull request, **Catch up with | |
| 368 | 368 | itself in seconds, with no agent; otherwise an agent merges it in a | |
| 369 | 369 | sandbox and resolves any conflict | |
| 370 | 370 | ([catching up](/guides/pull-requests/#catching-up)). | |
| 371 | + | - **Finish a security update.** g1t raises a vulnerable dependency to its | |
| 372 | + | fixed version itself, with no agent. When raising the version is not | |
| 373 | + | enough (the bump fails, or the pull request's required checks fail | |
| 374 | + | because code must change), g1t opens an issue and puts g1t-agent on it. | |
| 375 | + | That session shows as **started by g1t** on the project's **Agents** page. | |
| 376 | + | See [security updates](/guides/security/#security-updates). | |
| 371 | 377 | ||
| 372 | − | Both run in sandboxes of their own. | |
| 378 | + | Each runs in a sandbox of its own. | |
| 373 | 379 | ||
| 374 | 380 | ## Mentioning g1t-agent | |
| 375 | 381 | ||
| 382 | 388 | | An issue | A request: `@g1t-agent take this`, `@g1t-agent fix the empty case` | The issue is assigned to the agent, which opens a pull request, as if you had chosen **Assign**. | | |
| 383 | 389 | | An issue | A question: `@g1t-agent why does search time out?` | The agent reads the code on the default branch and answers in the thread. It changes nothing. | | |
| 384 | 390 | | A pull request g1t-agent made | A request: `@g1t-agent also handle the empty list` | The agent is sent back to make the change, with your comment as what to address, and the checks and review run again. If it is still working, it gets your comment as a message at its next step. | | |
| 385 | − | | Any pull request | `@g1t-agent review` | A review by a g1t agent, as with **Review by a g1t agent**. | | |
| 391 | + | | Any pull request | `@g1t-agent review` | A review by a g1t agent, as with **Request review from g1t agent**. | | |
| 386 | 392 | | Any pull request | A question | The agent reads the change at its head and answers in the thread. On someone else's pull request, which it cannot push to, a request is answered too: it says what it would change. | | |
| 387 | 393 | ||
| 388 | 394 | A request is a comment whose words after the mention start with what to | |
| 447 | 453 | ||
| 448 | 454 | ## How model traffic is routed | |
| 449 | 455 | ||
| 450 | − | g1t agents send model requests through | |
| 451 | − | [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/). The | |
| 452 | − | gateway is where an operator sees each request, caps spend, caches, and | |
| 453 | − | holds the provider's key so that no sandbox does. Each request is tagged | |
| 454 | − | with the kind of work, the repository and the pull request, so spend can be | |
| 455 | − | read per pull request. | |
| 456 | + | g1t agents send model requests to g1t's model proxy at | |
| 457 | + | `https://models.g1t.sh`, with a token for their run in place of a key; see | |
| 458 | + | [your keys never reach a sandbox](/guides/models/#your-keys-never-reach-a-sandbox). | |
| 459 | + | Requests for g1t's hosted models go on through | |
| 460 | + | [Cloudflare AI Gateway](https://developers.cloudflare.com/ai-gateway/), | |
| 461 | + | which holds g1t's key. Each of those requests is tagged with the kind of | |
| 462 | + | work, the repository and the pull request, so spend can be read per pull | |
| 463 | + | request. Requests for a workspace's own provider go to that provider. | |
| 456 | 464 | ||
| 457 | − | If you run your own copy of g1t, these settings on the runner control it: | |
| 465 | + | If you run your own copy of g1t, these settings control it: | |
| 458 | 466 | ||
| 459 | − | | Setting | What it does | | |
| 460 | − | | --- | --- | | |
| 461 | − | | `AGENT_ROUTES` | The model for each kind of work: `implement`, `review`, `update` and `plan`. | | |
| 462 | − | | `AI_GATEWAY_ID` | The gateway to route through. Empty sends requests to the provider directly. | | |
| 463 | − | | `AI_GATEWAY_TOKEN` | Secret. Authenticates to the gateway. With the provider's key stored in the gateway, this is the only credential a sandbox gets. | | |
| 464 | − | | `ANTHROPIC_API_KEY` | Secret. The provider's key, if the gateway does not hold it. | | |
| 467 | + | | Setting | Where | What it does | | |
| 468 | + | | --- | --- | --- | | |
| 469 | + | | `AGENT_ROUTES` | Runner | The model for each kind of work: `implement`, `review`, `update` and `plan`. | | |
| 470 | + | | `MODELS_URL` | Runner | Where sandboxes send model requests: the model proxy. | | |
| 471 | + | | `AI_GATEWAY_ID` | Model proxy | The gateway hosted requests go through. Empty sends them to the provider directly. | | |
| 472 | + | | `AI_GATEWAY_TOKEN` | Model proxy | Secret. Authenticates to the gateway. | | |
| 473 | + | | `ANTHROPIC_API_KEY` | Model proxy | Secret. The provider's key, if the gateway does not hold it. | | |
| 465 | 474 | ||
| 466 | 475 | ## What it costs | |
| 467 | 476 | ||
| 468 | 477 | A workspace pays for the g1t agents that work on its repositories, after | |
| 469 | − | they run: each run is charged what AI Gateway priced its model requests | |
| 470 | − | at, plus 20%, and its sandbox by the second, at cost plus 20%. See | |
| 478 | + | they run: each run is charged its sandbox by the second, at cost plus 20%, | |
| 479 | + | and, on g1t's hosted models, what AI Gateway priced its model requests at, | |
| 480 | + | plus 20%. A workspace's [own provider](/guides/models/) bills it for the | |
| 481 | + | model directly. See | |
| 471 | 482 | [Usage and billing](/guides/usage-and-billing/) for how prices are set and | |
| 472 | 483 | the limits on usage not yet paid for. | |
| 473 | 484 | The workspace's **Usage** page shows what its agents have cost, by day, |
| 41 | 41 | ## Creating a repository by pushing | |
| 42 | 42 | ||
| 43 | 43 | Pushing to a repository that does not exist, in a workspace you belong to, | |
| 44 | − | creates it as a public repository. | |
| 44 | + | creates it as a private repository, so nothing pushed by mistake is | |
| 45 | + | published. To make it public, see | |
| 46 | + | [change who can see a repository](/guides/managing-repositories/#change-who-can-see-a-repository). | |
| 45 | 47 | ||
| 46 | 48 | ```sh | |
| 47 | 49 | git push https://g1t.sh/<workspace>/new-repo.git main | |
| 119 | 121 | ||
| 120 | 122 | ## Limits | |
| 121 | 123 | ||
| 122 | − | Repositories are stored in Cloudflare Artifacts, which limits a repository to | |
| 123 | − | 1 GB and a single file to 32 MB. A single push is limited to 100 MB. | |
| 124 | + | ### Size limits | |
| 125 | + | ||
| 126 | + | Repositories are stored in Cloudflare Artifacts. g1t checks its limits | |
| 127 | + | before a push is stored, and declines a push that would cross one. git | |
| 128 | + | prints the reason beside each branch (`! [remote rejected] main (…)`), and | |
| 129 | + | what to do as `remote:` lines. Nothing in a declined push is stored. | |
| 130 | + | ||
| 131 | + | | Limit | Size | What happens past it | | |
| 132 | + | | --- | --- | --- | | |
| 133 | + | | A file | 32 MB | The push is declined, naming the file's size. | | |
| 134 | + | | A repository, with its pull requests' forks | 950 MB, as g1t counts what was pushed (the store holds 1 GB) | The push is declined; once full, pushes are refused with the reason before any data is sent. | | |
| 135 | + | | A push that push protection can scan before it lands | Most pushes; very large ones are scanned after they land | A very large push goes through and is scanned after it lands; secrets found are open alerts. To have it checked first, push in parts, oldest commits first. | | |
| 136 | + | | A push | 100 MB | Refused by the network with HTTP `413` before g1t sees it. | | |
| 137 | + | ||
| 138 | + | To push a large history in parts: | |
| 139 | + | ||
| 140 | + | ```sh | |
| 141 | + | git rev-list --reverse HEAD | awk 'NR % 500 == 0' | xargs -I{} git push origin {}:refs/heads/main | |
| 142 | + | git push origin main | |
| 143 | + | ``` | |
| 144 | + | ||
| 145 | + | Each push sends only what the one before did not. | |
| 146 | + | ||
| 147 | + | ### When the store is busy | |
| 148 | + | ||
| 149 | + | If Cloudflare Artifacts is rate limiting g1t or not answering, g1t tries | |
| 150 | + | reads again for a moment, then answers git with HTTP `429` (rate limited) | |
| 151 | + | or `503` (unavailable) and a `Retry-After` header saying how many seconds | |
| 152 | + | to wait. Pushes are never tried again on your behalf: run `git push` | |
| 153 | + | again. On g1t.sh the page says the git storage is busy instead of failing, | |
| 154 | + | and [status.g1t.sh](https://status.g1t.sh) shows **Git storage**. | |
| 155 | + | ||
| 156 | + | ### Git operations | |
| 124 | 157 | ||
| 125 | 158 | Each clone, fetch and push is a git operation. Every workspace has 50,000 | |
| 126 | 159 | a month included. Past that, a workspace on the g1t plan pays $0.18 per |
| 216 | 216 | ## Caps | |
| 217 | 217 | ||
| 218 | 218 | **Cost per run**: the most one run may spend on its model, in US dollars. | |
| 219 | − | $5.00 by default; 0 means no cap; at most $100. The harness tracks the | |
| 220 | − | run's spend as it goes and stops the agent when it reaches the cap. | |
| 219 | + | $2.00 by default, the same as the workspace billing's spend cap per run; | |
| 220 | + | 0 means no cap here, though billing's cap still applies; at most $100. The | |
| 221 | + | harness tracks the run's spend as it goes and stops the agent when it | |
| 222 | + | reaches the cap. | |
| 221 | 223 | ||
| 222 | 224 | **Time per run**: how long each kind of run may take, in minutes. By | |
| 223 | 225 | default: | |
| 237 | 239 | At most 240 minutes, but a run's credentials last two hours, so a longer | |
| 238 | 240 | cap does not give an agent more than that to push. | |
| 239 | 241 | ||
| 240 | − | **The workspace's plan** can set lower caps: a new paid workspace's first | |
| 241 | − | month, and the trial, cap every run's time and cost (see | |
| 242 | + | **The workspace's billing** sets caps too. Every workspace has a spend cap | |
| 243 | + | per run, $2 by default, which owners can set from $0.10 to $100, so a run | |
| 244 | + | stops at $2 unless an owner raises it (see | |
| 245 | + | [caps](/guides/usage-and-billing/#caps)). A new paid workspace's first | |
| 246 | + | month, and the trial, also cap every run's time at 60 minutes (see | |
| 242 | 247 | [who can run agents](/guides/g1t-agents/#who-can-run-agents)). A run gets | |
| 243 | 248 | the lower of its guardrails' cap and its plan's, for time and for cost, | |
| 244 | 249 | and its page shows the cap it got. |
| 8 | 8 | ||
| 9 | 9 | | Kind | Systems | What it does | | |
| 10 | 10 | | --- | --- | --- | | |
| 11 | − | | [Model providers](/guides/models/) | Anthropic, OpenAI, Google Gemini, and any Anthropic- or OpenAI-compatible endpoint | Your agents' model requests go to your own accounts, routed by kind of work. | | |
| 11 | + | | [Model providers](/guides/models/) | Anthropic, OpenAI, Google Gemini, xAI, Mistral, DeepSeek, Azure OpenAI, OpenRouter, Groq, Together AI, Fireworks AI, Cerebras, and any Anthropic- or OpenAI-compatible endpoint | Your agents' model requests go to your own accounts, routed by kind of work. | | |
| 12 | 12 | | [Alerts](#alerts) | Sentry, Datadog, a signed webhook | A problem opens an issue, once however often it fires, and an agent can start on it at once. | | |
| 13 | 13 | | [Trackers](#trackers) | Jira, Linear | Agents read the tickets that work mentions, people import tickets as issues, and tickets hear back when the work lands. | | |
| 14 | 14 | ||
| 219 | 219 | -d '{"provider": "jira", "config": {"site": "https://acme.atlassian.net", "email": "dev@acme.com", "keys": ["TECH"]}, "secret": "<api token>"}' | |
| 220 | 220 | ``` | |
| 221 | 221 | ||
| 222 | − | `provider` is `anthropic`, `openai`, `gemini`, `anthropic_endpoint`, | |
| 223 | − | `openai_endpoint`, `sentry`, `datadog`, `webhook`, `jira` or `linear`. `config` takes `repo`, `assign`, `label`, | |
| 222 | + | `provider` is one of the [model providers](/guides/models/#from-the-api), | |
| 223 | + | or `sentry`, `datadog`, `webhook`, `jira` or `linear`. `config` takes `repo`, `assign`, `label`, | |
| 224 | 224 | `write_back`, `organization`, `site`, `email`, `keys`, `base_url`, | |
| 225 | 225 | `auth_header` and `model`; each provider uses the ones above. For `datadog` | |
| 226 | 226 | and `webhook`, the response's `signing_secret` is the only time the secret |
| 66 | 66 | -d '{"description": "Launches things.", "website": "rocket.acme.dev", "topics": ["cli", "rust"]}' | |
| 67 | 67 | ``` | |
| 68 | 68 | ||
| 69 | − | The MCP tool is `update_repo`, with `repo` and the same fields. | |
| 69 | + | Over MCP, it is the `repository` tool's `update` action, with `repo` and | |
| 70 | + | the same fields. | |
| 70 | 71 | ||
| 71 | 72 | ## Change the default branch | |
| 72 | 73 | ||
| 96 | 97 | -d '{"default_branch": "trunk"}' | |
| 97 | 98 | ``` | |
| 98 | 99 | ||
| 99 | − | The MCP tool is `update_repo`, with `repo` and `default_branch`. When the | |
| 100 | + | Over MCP, it is the `repository` tool's `update` action, with `repo` and | |
| 101 | + | `default_branch`. When the | |
| 100 | 102 | same call changes other fields, they are changed first. | |
| 101 | 103 | ||
| 102 | 104 | ## Rename a branch | |
| 135 | 137 | -d '{"new_name": "feature/sign-in"}' | |
| 136 | 138 | ``` | |
| 137 | 139 | ||
| 138 | − | The MCP tool is `rename_branch`, with `repo`, `branch` and `new_name`. | |
| 140 | + | Over MCP, it is the `repository` tool's `rename_branch` action, with | |
| 141 | + | `repo`, `branch` and `new_name`. | |
| 139 | 142 | ||
| 140 | 143 | ## Rename a repository | |
| 141 | 144 | ||
| 182 | 185 | -d '{"name": "launcher"}' | |
| 183 | 186 | ``` | |
| 184 | 187 | ||
| 185 | − | The MCP tool is `rename_repo`, with `repo` and `name`. | |
| 188 | + | Over MCP, it is the `repository` tool's `rename` action, with `repo` and | |
| 189 | + | `name`. | |
| 186 | 190 | ||
| 187 | 191 | ## Change who can see a repository | |
| 188 | 192 | ||
| 217 | 221 | -d '{"private": true, "confirm": "acme/rocket"}' | |
| 218 | 222 | ``` | |
| 219 | 223 | ||
| 220 | − | The MCP tool is `set_repo_visibility`, with `repo`, `private` and | |
| 221 | − | `confirm`. `private` on | |
| 224 | + | Over MCP, it is the `repository` tool's `set_visibility` action, with | |
| 225 | + | `repo`, `private` and `confirm`. `private` on | |
| 222 | 226 | [`PATCH /repos/{owner}/{name}`](/reference/api/repositories/update-repo/) | |
| 223 | 227 | makes the same change without the confirmation, for people with Admin. | |
| 224 | 228 | ||
| 259 | 263 | -H "Authorization: Bearer $G1T_TOKEN" | |
| 260 | 264 | ``` | |
| 261 | 265 | ||
| 262 | − | The MCP tools are `archive_repo` and `unarchive_repo`, with `repo`. The | |
| 266 | + | Over MCP, they are the `repository` tool's `archive` and `unarchive` | |
| 267 | + | actions, with `repo`. The | |
| 263 | 268 | repository's `archived_at` field says when it was archived, and is null | |
| 264 | 269 | when it is not. | |
| 265 | 270 | ||
| 300 | 305 | -d '{"confirm": "acme/rocket"}' | |
| 301 | 306 | ``` | |
| 302 | 307 | ||
| 303 | − | The MCP tool is `delete_repo`, with `repo` and `confirm`. | |
| 308 | + | Over MCP, it is the `repository` tool's `delete` action, with `repo` and | |
| 309 | + | `confirm`. | |
| 304 | 310 | ||
| 305 | 311 | To move a repository to another workspace instead of deleting it, see | |
| 306 | 312 | [transferring a repository](/guides/transferring-repositories/). | |
| 323 | 329 | ||
| 324 | 330 | From the API, list them with | |
| 325 | 331 | [`GET /workspaces/{workspace}/repos/deleted`](/reference/api/repositories/list-deleted-repos/) | |
| 326 | − | (MCP: `list_deleted_repos`), then call | |
| 332 | + | (the `repository` tool's `list_deleted` action over MCP), then call | |
| 327 | 333 | [`POST /repos/{owner}/{name}/restore`](/reference/api/repositories/restore-repo/) | |
| 328 | − | with the path it had (MCP: `restore_repo`): | |
| 334 | + | with the path it had (the `restore` action): | |
| 329 | 335 | ||
| 330 | 336 | ```sh | |
| 331 | 337 | curl https://api.g1t.sh/workspaces/acme/repos/deleted \ | |
| 356 | 362 | -d '{"confirm": "acme/rocket"}' | |
| 357 | 363 | ``` | |
| 358 | 364 | ||
| 359 | − | The MCP tool is `purge_repo`, with `repo` and `confirm`. | |
| 365 | + | Over MCP, it is the `repository` tool's `purge` action, with `repo` and | |
| 366 | + | `confirm`. | |
| 360 | 367 | ||
| 361 | 368 | A workspace whose only repositories are recently deleted ones can itself | |
| 362 | 369 | be [deleted](/guides/workspaces/#delete-a-workspace); they are purged with |
| 9 | 9 | on, a pull request is tested together with everything ahead of it before it | |
| 10 | 10 | lands, and `main` only ever moves to a state whose required checks passed. | |
| 11 | 11 | ||
| 12 | − | The merge queue runs in g1t's sandboxes, which work in any workspace with | |
| 13 | − | [its own model provider](/guides/models/) and in those g1t's hosted models | |
| 14 | − | are open to. Elsewhere, an entry fails at once with a message saying so; | |
| 15 | − | turn the queue off to merge directly. | |
| 12 | + | The merge queue runs in g1t's sandboxes, which need | |
| 13 | + | [the g1t plan](/guides/usage-and-billing/#the-g1t-plan) or the trial after | |
| 14 | + | a card check; a public repository can use the open-source pool instead. | |
| 15 | + | Without one, an entry fails at once with a message saying so; turn the | |
| 16 | + | queue off to merge directly. | |
| 16 | 17 | ||
| 17 | 18 | ## Turn it on | |
| 18 | 19 |
| 8 | 8 | - **g1t's hosted models.** g1t chooses the model for each kind of work, pays | |
| 9 | 9 | the provider, and charges your workspace what it cost plus 20%. The | |
| 10 | 10 | plan's included usage and [the trial](/guides/usage-and-billing/#the-trial) | |
| 11 | − | pay for it first. Open only to g1t's own workspaces while payments are | |
| 12 | − | in test mode; once they go live, open to all. | |
| 11 | + | pay for it first. While payments are in test mode, they are open only | |
| 12 | + | to a few invited workspaces, g1t's own among them; a card check or a | |
| 13 | + | trial does not open them. Every other workspace connects its own | |
| 14 | + | provider, and an agent assigned without one is refused with a message | |
| 15 | + | that says so. Once payments go live, they are open to all. | |
| 13 | 16 | - **Your own providers.** Connect as many as you use, then choose, for each | |
| 14 | 17 | kind of work, which provider and model it runs on. Each provider bills | |
| 15 | 18 | you directly. Open to every workspace now. | |
| 41 | 44 | gateway's token, sent as `cf-aig-authorization`. | |
| 42 | 45 | ||
| 43 | 46 | g1t's agents run Claude Code, which speaks Anthropic's API. Every provider | |
| 44 | − | but Anthropic speaks OpenAI's, so g1t's model proxy translates each | |
| 47 | + | but Anthropic and an Anthropic-compatible endpoint speaks OpenAI's, so g1t's model proxy translates each | |
| 45 | 48 | request, and the streamed answer back, tool calls included, and meets each | |
| 46 | 49 | provider's quirks: the token limits DeepSeek and Groq set, how Mistral | |
| 47 | 50 | names a required tool, Azure's `api-key` header, and the thought signatures | |
| 72 | 75 | | Catching up | Bringing a change up to date with `main`. | | |
| 73 | 76 | ||
| 74 | 77 | Each can go to g1t's models, or to any of your providers on any of its | |
| 75 | − | models. An Anthropic provider also offers **g1t's choice of Claude model**, | |
| 78 | + | models. An Anthropic provider also offers **g1t's choice of Claude**, | |
| 76 | 79 | which runs g1t's pick for that kind of work on your key. For example: make | |
| 77 | 80 | changes on Claude through your Anthropic key, review on GPT through your | |
| 78 | 81 | OpenAI key, and catch up on a small model through OpenRouter. | |
| 107 | 110 | work is routed to, translates if the provider speaks OpenAI's API, and | |
| 108 | 111 | forwards the request. Answers stream straight back. | |
| 109 | 112 | ||
| 110 | − | The token stops working when the run ends (three hours at most), or at once | |
| 111 | − | if you disconnect the provider. Keys are sealed when you save them, and used | |
| 113 | + | The token stops working within seconds of the run finishing, however it | |
| 114 | + | ends, and within seconds if you disconnect the provider. A run whose end | |
| 115 | + | g1t never hears about loses it three hours after it starts. Keys are sealed when you save them, and used | |
| 112 | 116 | only by the proxy. g1t's own runs work the same way, with g1t's key. | |
| 113 | 117 | ||
| 114 | 118 | ## From the API | |
| 115 | 119 | ||
| 116 | − | | Tool | Route | | |
| 120 | + | | MCP tool and action | Route | | |
| 117 | 121 | | --- | --- | | |
| 118 | − | | `connect_integration` | `POST /workspaces/{workspace}/integrations` with `provider` one of `anthropic`, `openai`, `gemini`, `xai`, `mistral`, `deepseek`, `azure_openai`, `openrouter`, `groq`, `together`, `fireworks`, `cerebras`, `anthropic_endpoint`, `openai_endpoint` | | |
| 119 | − | | `get_model_routes` | `GET /workspaces/{workspace}/model-routes` | | |
| 120 | − | | `set_model_routes` | `PUT /workspaces/{workspace}/model-routes` | | |
| 122 | + | | `workspace` `connect_integration` | `POST /workspaces/{workspace}/integrations` with `provider` one of `anthropic`, `openai`, `gemini`, `xai`, `mistral`, `deepseek`, `azure_openai`, `openrouter`, `groq`, `together`, `fireworks`, `cerebras`, `anthropic_endpoint`, `openai_endpoint` | | |
| 123 | + | | `workspace` `get_model_routes` | `GET /workspaces/{workspace}/model-routes` | | |
| 124 | + | | `workspace` `set_model_routes` | `PUT /workspaces/{workspace}/model-routes` | | |
| 121 | 125 | ||
| 122 | 126 | ```sh | |
| 123 | 127 | curl -X PUT https://api.g1t.sh/workspaces/acme/model-routes \ |
| 10 | 10 | it. g1t agents then work on the issues, as many at once as the dependencies | |
| 11 | 11 | allow, and the outcome page shows each one until it lands. | |
| 12 | 12 | ||
| 13 | − | Planning and g1t agents work in any workspace with | |
| 14 | − | [its own model provider](/guides/models/), and in those g1t's hosted models | |
| 15 | − | are open to. The agents' runs are charged to the workspace; see | |
| 13 | + | Planning and g1t agents work in any workspace that | |
| 14 | + | [can run agents](/guides/g1t-agents/#who-can-run-agents) (the plan or the | |
| 15 | + | trial) and has a model: [its own model provider](/guides/models/), or g1t's | |
| 16 | + | hosted models where they are open to it. The agents' runs are charged to | |
| 17 | + | the workspace; see | |
| 16 | 18 | [usage and billing](/guides/usage-and-billing/). Planning work for a | |
| 17 | 19 | repository needs the Write [role](/guides/access-and-roles/) or higher on it; anyone who can | |
| 18 | 20 | read it can see its plans. |
| 97 | 97 | | --- | --- | | |
| 98 | 98 | | **General** | The project's name and description, its source, and its **root directory**. A project shows its repository's description, and follows it as it changes, until you give the project one of its own; **Use the repository's description** goes back. | | |
| 99 | 99 | | **Deployments** | Production, previews, build command, output directory and idle days. See [Deployments](/guides/deployments/#settings). | | |
| 100 | + | | **Domains** | Custom domains for production. See [custom domains](/guides/deployments/#custom-domains). | | |
| 100 | 101 | | **Dependencies** | The projects this one uses, and the ones that use it. See [Dependencies](#dependencies). | | |
| 101 | − | | **Secrets and variables** | The project's rows. See [Secrets and variables](/guides/secrets-and-variables/). | | |
| 102 | + | | **Agents** | How g1t's agents pick up work here, and what they read first. | | |
| 103 | + | | **Guardrails** | What agents may reach, run and spend while they work here. See [guardrails](/guides/guardrails/). | | |
| 102 | 104 | | **Repository** | The repository's name, description, website, [topics](/guides/search/#what-is-indexed) and default branch, and its danger zone: visibility, archive, transfer and delete. See [Managing a repository](/guides/managing-repositories/). | | |
| 105 | + | | **Access** | Who has a [role](/guides/access-and-roles/) on the repository, and invitations. | | |
| 103 | 106 | | **Branches and merging** | Branch protection, [required status checks](/guides/pull-requests/#required-status-checks), required approvals, the merge queue, auto-merge and how g1t's agents review. | | |
| 107 | + | | **Secrets and variables** | The project's rows. See [Secrets and variables](/guides/secrets-and-variables/). | | |
| 108 | + | | **Runners** | The project's own [self-hosted runners](/guides/self-hosted-runners/), and where its agents' work runs. | | |
| 104 | 109 | | **Webhooks** | The repository's [webhooks](/guides/webhooks/). | | |
| 105 | 110 | ||
| 106 | 111 | The **root directory** says where in the repository the project lives, | |
| 112 | 117 | ||
| 113 | 118 | | Tab | Needs | | |
| 114 | 119 | | --- | --- | | |
| 115 | − | | **General**, **Dependencies**, and on **Repository** its description, website and topics | Maintain | | |
| 120 | + | | **General**, **Dependencies**, **Agents**, and on **Repository** its description, website and topics | Maintain | | |
| 116 | 121 | | **Branches and merging**, **Guardrails** | Maintain | | |
| 117 | − | | **Deployments**, **Domains**, **Secrets and variables**, **Webhooks** | Admin | | |
| 122 | + | | **Access**: seeing who has a role; changing it | Write; Admin | | |
| 123 | + | | **Deployments**, **Domains**, **Secrets and variables**, **Runners**, **Webhooks** | Admin | | |
| 118 | 124 | | On **Repository**: its name, default branch, and the danger zone (visibility, archive) | Admin | | |
| 119 | 125 | | Transfer and delete | An owner of the workspace | | |
| 120 | 126 |
| 22 | 22 | | Field | | | |
| 23 | 23 | | --- | --- | | |
| 24 | 24 | | **Type** | **Secret**: sealed when saved and never shown again, hidden in logs. For passwords, API keys and tokens. **Config**: readable by whoever may see the list. For values that are not sensitive. Config can be changed to a secret; a secret can never become config. | | |
| 25 | − | | **Key** | Letters, digits and underscores, upper-cased: `STRIPE_KEY`. Keys starting with `G1T_` or `GITHUB_` are g1t's own. | | |
| 25 | + | | **Key** | Letters, digits and underscores, not starting with a digit, upper-cased: `STRIPE_KEY`. Keys starting with `G1T_` or `GITHUB_` are g1t's own. | | |
| 26 | 26 | | **Value** | Up to 48 KB. | | |
| 27 | 27 | | **Note** | Optional: where to rotate it, or who to ask. | | |
| 28 | 28 | | **Environments** | **All environments**, or only some: **Production**, **Preview**, or any name a workflow job uses in `environment:`, such as `staging`. | | |
| 113 | 113 | key named without an `id` or `environments` is the key's row for all | |
| 114 | 114 | environments. | |
| 115 | 115 | ||
| 116 | − | | Tool | Route | | |
| 116 | + | | MCP (`secret` tool) | Route | | |
| 117 | 117 | | --- | --- | | |
| 118 | − | | `list_actions_secrets` | `GET /repos/{owner}/{repo}/actions/secrets` | | |
| 119 | − | | `set_actions_secret` | `PUT /repos/{owner}/{repo}/actions/secrets/{key}` | | |
| 120 | − | | `delete_actions_secret` | `DELETE /repos/{owner}/{repo}/actions/secrets/{key}` | | |
| 121 | − | | `list_actions_variables` | `GET /repos/{owner}/{repo}/actions/variables` | | |
| 122 | − | | `set_actions_variable` | `POST /repos/{owner}/{repo}/actions/variables`, `PATCH …/variables/{key}` | | |
| 123 | − | | `delete_actions_variable` | `DELETE /repos/{owner}/{repo}/actions/variables/{key}` | | |
| 118 | + | | `list_secrets` | `GET /repos/{owner}/{repo}/actions/secrets` | | |
| 119 | + | | `set_secret` | `PUT /repos/{owner}/{repo}/actions/secrets/{key}` | | |
| 120 | + | | `delete_secret` | `DELETE /repos/{owner}/{repo}/actions/secrets/{key}` | | |
| 121 | + | | `list_variables` | `GET /repos/{owner}/{repo}/actions/variables` | | |
| 122 | + | | `set_variable` | `POST /repos/{owner}/{repo}/actions/variables`, `PATCH …/variables/{key}` | | |
| 123 | + | | `delete_variable` | `DELETE /repos/{owner}/{repo}/actions/variables/{key}` | | |
| 124 | 124 | ||
| 125 | 125 | A workspace's are under `/workspaces/{workspace}/actions/secrets` and | |
| 126 | 126 | `…/variables`. Beyond GitHub's fields, a row takes: | |
| 139 | 139 | -d '{"value":"sk_live_…","environments":["production"],"available_to":["deployments"]}' | |
| 140 | 140 | ``` | |
| 141 | 141 | ||
| 142 | − | Unlike GitHub's, a secret is sent as plain `value` over HTTPS, not | |
| 143 | − | encrypted to a public key. | |
| 142 | + | A secret is sent as plain `value` over HTTPS, not encrypted to a public | |
| 143 | + | key first. |
| 1 | − | --- | |
| 2 | − | title: Security | |
| 3 | − | description: How g1t keeps secrets out of your repositories, finds vulnerable dependencies, and has an agent land the upgrades that fix them. | |
| 4 | − | --- | |
| 5 | − | ||
| 6 | − | Every project has a **Security** page at `g1t.sh/<owner>/<project>/security`. | |
| 7 | − | It shows what g1t has found, and what is being done about each finding: | |
| 8 | − | ||
| 9 | − | - **Secrets.** A push that adds a key or a token is refused before it lands, | |
| 10 | − | and each repository's history is scanned once, in the background. | |
| 11 | − | - **Dependencies.** Every package your lockfiles resolve is checked against | |
| 12 | − | the [OSV](https://osv.dev) database of known vulnerabilities. Each | |
| 13 | − | vulnerable package that has a fix gets an upgrade issue, and a g1t agent | |
| 14 | − | lands the upgrade through the usual pull request, checks, review and merge | |
| 15 | − | queue. | |
| 16 | − | ||
| 17 | − | Findings are the workspace's to fix, so the Security page needs a | |
| 18 | − | [role](/guides/access-and-roles/) on the repository, whether the project | |
| 19 | − | is public or private: | |
| 20 | − | ||
| 21 | − | | | Needs | | |
| 22 | − | | --- | --- | | |
| 23 | − | | See findings, **Re-scan now** | Write | | |
| 24 | − | | **Upkeep agents** on or off | Maintain | | |
| 25 | − | | **Allow**, **Resolve** or **Reopen** a secret | Admin | | |
| 26 | − | ||
| 27 | − | Someone with Read or Triage is told the page needs Write. The workspace's | |
| 28 | − | own page, `g1t.sh/<owner>/-/security`, lists the open findings of every | |
| 29 | − | project the member can see them on, most severe first. | |
| 30 | − | ||
| 31 | − | ## The overview | |
| 32 | − | ||
| 33 | − | The top of the page counts open findings by severity: critical, high, | |
| 34 | − | medium, low and unrated. A secret that is open, or that stopped a push, | |
| 35 | − | counts as critical. Below the counts: | |
| 36 | − | ||
| 37 | − | - **Re-scan now** reads the dependencies again at once and scans the | |
| 38 | − | history again from the start. | |
| 39 | − | - **Upkeep agents** turns upgrade issues on or off for the project. It is on | |
| 40 | − | unless someone turns it off. With it off, findings are still listed, and | |
| 41 | − | nothing is opened for them. | |
| 42 | − | ||
| 43 | − | The **Secrets** and **Dependencies** tabs list each finding with its status | |
| 44 | − | and the issue or pull request fixing it. | |
| 45 | − | ||
| 46 | − | ## Secret scanning | |
| 47 | − | ||
| 48 | − | g1t looks for credentials whose format their issuer made recognisable, so a | |
| 49 | − | match is nearly always a real secret or a fake made to look like one: | |
| 50 | − | ||
| 51 | − | | What | What it looks like | | |
| 52 | − | | --- | --- | | |
| 53 | − | | AWS access keys | `AKIA` or `ASIA` and 16 more characters | | |
| 54 | − | | AWS secret access keys | 40 characters on a line that names an AWS secret | | |
| 55 | − | | GitHub tokens | `ghp_`, `gho_`, `ghu_`, `ghs_`, `ghr_`, `github_pat_` | | |
| 56 | − | | GitLab tokens | `glpat-`, `gloas-`, `glrt-`, `glptt-`, `gldt-` | | |
| 57 | − | | Stripe live keys | `sk_live_`, `rk_live_` (test keys are left alone) | | |
| 58 | − | | Slack | `xoxb-`, `xoxp-` and other tokens, and incoming webhook addresses | | |
| 59 | − | | Google API keys | `AIza` and 35 more characters | | |
| 60 | − | | Anthropic API keys | `sk-ant-` | | |
| 61 | − | | OpenAI API keys | `sk-proj-`, `sk-svcacct-`, `sk-admin-`, and older `sk-` keys | | |
| 62 | − | | Private keys | A PEM `BEGIN … PRIVATE KEY` header followed by the key | | |
| 63 | − | | Service-role JWTs | A JWT whose claims carry `service_role` | | |
| 64 | − | | npm tokens | `npm_` | | |
| 65 | − | | g1t tokens | `g1t_` and 40 hex characters | | |
| 66 | − | | SendGrid keys | `SG.` and two dotted parts | | |
| 67 | − | ||
| 68 | − | Placeholders such as `ghp_xxxxxxxx…` are not reported. Lockfiles, and files | |
| 69 | − | under `node_modules/` and `vendor/`, are not scanned. | |
| 70 | − | ||
| 71 | − | g1t never stores a secret it finds. It keeps a fingerprint, so it can | |
| 72 | − | recognise the same secret again, and a short preview, such as `AKIA…`, so | |
| 73 | − | you can recognise it. | |
| 74 | − | ||
| 75 | − | ### Push protection | |
| 76 | − | ||
| 77 | − | When a push over HTTPS adds a secret, g1t refuses the whole push and nothing | |
| 78 | − | is stored. Only the lines the push adds are checked, so a secret that is | |
| 79 | − | already in the repository does not block every later push to the same file. | |
| 80 | − | This applies to every push, including the pushes g1t's agents make to their | |
| 81 | − | pull requests. | |
| 82 | − | ||
| 83 | − | Git shows why, file and line: | |
| 84 | − | ||
| 85 | − | ```text | |
| 86 | − | $ git push | |
| 87 | − | remote: g1t found a secret in this push, so nothing was pushed. | |
| 88 | − | remote: | |
| 89 | − | remote: config/prod.env:3 an AWS access key (commit 4807077) | |
| 90 | − | remote: | |
| 91 | − | remote: Take the secret out of the commit that adds it (git commit --amend, or | |
| 92 | − | remote: git rebase -i for an older commit), rotate it if it was ever real, and | |
| 93 | − | remote: push again. | |
| 94 | − | remote: | |
| 95 | − | remote: If it is not a real secret, such as a test fixture: | |
| 96 | − | remote: - add g1t:allow-secret in a comment on its line, or | |
| 97 | − | remote: - allow it once at https://g1t.sh/acme/rocket/security?tab=secrets&finding=sec_… | |
| 98 | − | remote: Allowing is recorded with your name, then the same push goes through. | |
| 99 | − | To https://g1t.sh/acme/rocket.git | |
| 100 | − | ! [remote rejected] main -> main (secret found: config/prod.env:3 has an AWS access key) | |
| 101 | − | ``` | |
| 102 | − | ||
| 103 | − | To fix a real secret: | |
| 104 | − | ||
| 105 | − | 1. Remove it from the commit that adds it: `git commit --amend` for the last | |
| 106 | − | commit, `git rebase -i` for an older one. | |
| 107 | − | 2. Rotate it with whoever issued it. Once a secret has been on any machine | |
| 108 | − | but yours, treat it as known. | |
| 109 | − | 3. Push again. | |
| 110 | − | ||
| 111 | − | ### Allowing a false positive | |
| 112 | − | ||
| 113 | − | A fake key in a test, or an example in documentation, is still reported, on | |
| 114 | − | purpose: it looks exactly like a real one. Two ways to let it through: | |
| 115 | − | ||
| 116 | − | - **Mark the line.** Put `g1t:allow-secret` anywhere on the line, usually in | |
| 117 | − | a comment. The line is never reported, in any push or in history. | |
| 118 | − | ||
| 119 | − | ```ts | |
| 120 | − | const FIXTURE_KEY = "AKIA…"; // g1t:allow-secret | |
| 121 | − | ``` | |
| 122 | − | ||
| 123 | − | - **Allow it once.** Open the link in git's message (or the finding on the | |
| 124 | − | **Secrets** tab), choose **Allow**, and say why. g1t records who allowed | |
| 125 | − | it and why. Then push again, unchanged: that secret no longer stops a push | |
| 126 | − | to this project. | |
| 127 | − | ||
| 128 | − | Allowing needs the Admin [role](/guides/access-and-roles/) on the repository. Someone else | |
| 129 | − | pushing to a pull request's fork sees the same message and asks someone | |
| 130 | − | who has it. | |
| 131 | − | ||
| 132 | − | ### Secrets in history | |
| 133 | − | ||
| 134 | − | The first time g1t sees a repository (when it is created, on its next push | |
| 135 | − | to the default branch, or when its Security page is first opened) it scans | |
| 136 | − | the default branch's history in the background, a page of commits at a | |
| 137 | − | time, comparing each commit with its first parent. The page shows how far | |
| 138 | − | it has got. Scanning is metered to the workspace like other usage, and it | |
| 139 | − | pauses if the workspace reaches its spending limit. | |
| 140 | − | ||
| 141 | − | A secret found in history is **Open**: it is in the repository, and anyone | |
| 142 | − | who could clone it may have it. Rotate it, then choose **Resolve** and say | |
| 143 | − | what you did. Removing it from the code is not enough, since it stays in | |
| 144 | − | history. | |
| 145 | − | ||
| 146 | − | | Status | Meaning | | |
| 147 | − | | --- | --- | | |
| 148 | − | | Open | In the repository's history. Rotate it, then resolve it. | | |
| 149 | − | | Push blocked | A push carrying it was refused, so it never landed. | | |
| 150 | − | | Allowed | Someone said it is not a real secret; pushes carrying it go through. | | |
| 151 | − | | Resolved | Someone rotated or removed it. | | |
| 152 | − | ||
| 153 | − | **Reopen** undoes an allow or a resolve. | |
| 154 | − | ||
| 155 | − | ## Dependency upkeep | |
| 156 | − | ||
| 157 | − | g1t reads these lockfiles on the default branch, up to four directories | |
| 158 | − | deep, skipping `node_modules`, `vendor`, `target`, `dist` and `build`: | |
| 159 | − | ||
| 160 | − | | Ecosystem | Files | | |
| 161 | − | | --- | --- | | |
| 162 | − | | npm | `package-lock.json`, `pnpm-lock.yaml`, `yarn.lock` | | |
| 163 | − | | Cargo | `Cargo.lock` | | |
| 164 | − | | Go | `go.mod` (or `go.sum` where there is no `go.mod`) | | |
| 165 | − | | Python | `poetry.lock`, and pinned lines (`name==1.2.3`) in `requirements.txt` | | |
| 166 | − | ||
| 167 | − | It reads them on every push to the default branch and again every day, and | |
| 168 | − | asks OSV about every package at the exact version locked. Each advisory | |
| 169 | − | found is listed with its id (its GHSA id when it has one), severity, the | |
| 170 | − | version that fixes it, and the lockfile that resolves the vulnerable | |
| 171 | − | version. A vulnerability that a later scan no longer finds is marked fixed. | |
| 172 | − | ||
| 173 | − | ### Upgrade issues | |
| 174 | − | ||
| 175 | − | With **Upkeep agents** on, each vulnerable package that has a fixed version | |
| 176 | − | gets one issue, labelled `dependencies` and `security`, such as: | |
| 177 | − | ||
| 178 | − | > Upgrade lodash to 4.17.21: fixes GHSA-35jh-r3h4-6jhm | |
| 179 | − | ||
| 180 | − | The issue lists every advisory it fixes, and the target is the lowest | |
| 181 | − | version that fixes all of them. It ends with a **Definition of done**: no | |
| 182 | − | lockfile resolves a vulnerable version and the tests still pass, followed | |
| 183 | − | by the commands that show it, each as "`command` passes.": | |
| 184 | − | ||
| 185 | − | - a command per lockfile that fails while the lockfile still resolves the | |
| 186 | − | vulnerable version, and | |
| 187 | − | - the project's tests, run in the lockfile's directory by its package | |
| 188 | − | manager: | |
| 189 | − | ||
| 190 | − | | Lockfile | Tests | | |
| 191 | − | | --- | --- | | |
| 192 | − | | `package-lock.json` | `npm ci && npm test --if-present` | | |
| 193 | − | | `pnpm-lock.yaml` | `pnpm install --frozen-lockfile && pnpm test --if-present` | | |
| 194 | − | | `yarn.lock` | `yarn install`, then `yarn test` | | |
| 195 | − | | `Cargo.lock` | `cargo test --locked` | | |
| 196 | − | | `go.mod`, `go.sum` | `go test ./...` | | |
| 197 | − | ||
| 198 | − | Python projects get the lockfile command only, since there is no one way | |
| 199 | − | to run their tests. | |
| 200 | − | ||
| 201 | − | The definition of done tells the agent and its reviewer what to verify. The | |
| 202 | − | pull request merges on the repository's | |
| 203 | − | [required status checks](/guides/pull-requests/#required-status-checks), | |
| 204 | − | as any other does. | |
| 205 | − | ||
| 206 | − | g1t puts its agent on the first new upgrade at once and queues the rest, | |
| 207 | − | which start as the project has room for more agents. The agent upgrades the | |
| 208 | − | package, changes whatever code the upgrade breaks, and opens a pull request | |
| 209 | − | that goes through checks, review and the merge queue like any other. When it | |
| 210 | − | merges, the next scan finds the vulnerability fixed. | |
| 211 | − | ||
| 212 | − | g1t opens at most eight upgrade issues per scan, most severe first, and | |
| 213 | − | never a second issue for a package while one is open. If you close an | |
| 214 | − | upgrade issue as not planned, g1t does not open it again. If agents cannot | |
| 215 | − | run in the workspace, the issue stays open with a comment saying why, for | |
| 216 | − | you to assign once they can, or to upgrade by hand. | |
| 217 | − | ||
| 218 | − | Turn **Upkeep agents** off on the Security page to stop new upgrade issues | |
| 219 | − | for a project. Issues already open are left as they are. |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.