Skip to content

Security: dimipaun/fugaro

SECURITY.md

Security policy

In short

  • Report a vulnerability through GitHub's private vulnerability reporting, never in a public issue (Reporting a vulnerability).
  • The design assumes you trust the repository and the source of the task: anyone who can push to it, launch a run, or is listed in followup.trusted can steer an agent that holds the credentials below, and this is not a sandbox for code you would not run on a developer's laptop (The assumption).
  • What limits the blast radius today: one container per run, per-repository identities, and narrow git credentials — a project-wide credential is not narrow (What limits the blast radius today).
  • What is not on by default: the budget gateway, so with no budget: block the real model key sits in the agent's environment, and Vertex has no dollar cap in any mode; nor the credentials service's switch, which is per repository — fugaro update deploys the service on every installation, and a repository's jobs keep their model credentials until its own root has been re-applied with the switch on (What is not on by default, The credentials service).
  • What no boundary stops: network egress is unrestricted, the agent shares the container with the runner and can read its /proc, and the caps are the gateway's — set a credit limit on every key you give Fugaro (Known limits, Money).
  • Out of scope: an agent doing harmful things inside its own container with the credentials that job's service account is allowed to hold (Scope).

Reporting a vulnerability

Please do not open a public issue for a vulnerability. Use GitHub's private vulnerability reporting: open the repository's Security tab, then Report a vulnerability. Include what you found, how to reproduce it, and the version (fugaro version) or commit.

We will acknowledge a report as soon as we can, work on a fix with you privately, and credit you in the advisory if you wish. This is a small project maintained by volunteers, so we cannot promise a fixed response time.

Supported versions

The current minor release and the one before it are supported. Fugaro is pre-1.0; fixes land on main and ship in the next release.

Trust model and known limits

Fugaro runs a coding agent with its permission prompts turned off, in a fresh cloud container per run, and ends every run with a pull request. This section says what that design protects, what it does not, and what you should do about the gaps. It describes the code as it is; the details, with file references, are in docs/design/v1.md §6.

Project scope is the default for a name that has one

fugaro secrets set NAME stores the value once for the whole installation — in the project's own Secret Manager, not a repository's — unless --repo is passed, which stores one repository's own copy instead. --project-wide is still accepted as a synonym of the default, so scripts written before the change keep working. This reverses the earlier rule, under which nothing was shared until the owner allow-listed a repository: storing at project scope now is the opt-in, and that is the point of the change — the default is the one an operator can see, rather than the one they have to remember. A name that has no project-wide meaning (a workflow's own secrets: entry that no workflow_secrets entry in the operator's local config covers, or the default Claude account's claude-oauth-token) keeps the repository scope it had. bitbucket-token is the other exception, and it is not a narrow case of the default but the absence of one: its default is a repository access token, issued for one repository and usable for no other (Bitbucket's own scoping), so fugaro secrets set bitbucket-token stores the repository's own copy — the project-wide store is refused for it unless the operator's local config allow-lists repositories in project_secrets.bitbucket-token, and an operator who holds one workspace or project access token may instead mount it in many repositories at once through the allow-list, which the owner ruled on 2026-10-27 they may opt in to (see "Narrow git credentials" below).

The reach is every enrolled repository. Until a routing policy is published, that means every repository the local config knows; once one is published, every repository that policy admits. Either way this is the whole installation, not the repository whose job holds the value.

An allow_data_to entry may name a workspace, not only one repository. project_secrets.<name>.allow_data_to accepts * inside a segment — myworkspace/*, myworkspace/api-* — never across /, and the owner segment must be literal, so a bare * and */* are refused and no entry can mean every workspace at once (owner ruling, 2026-10-27). A wildcard means every current AND future repository it matches. A repository onboarded into that workspace later mounts this value on its next fugaro setup, with no further edit to the list, and nobody is asked again — that is the point of writing one entry instead of fifty, and it is the risk. Store a credential that way only when its reach really is the whole workspace, keep it at the minimum scopes the runs need (a Bitbucket workspace token with repository read/write and pull request read/write, no more), and prefer naming repositories one by one when only some of them need it. claude_accounts.<name>.allow_data_to takes exact names only, and so does a provider's allow_data_to with one widening that predates this rule and is wider than anything here: a provider key whose list is absent, or written as the literal ["*"], is open to every repository of the installation — every one of them may mount that key and send its code to that provider (the owner's ruling of 2026-10-13, docs/design/project-secrets.md §3a) — which fugaro validate and fugaro doctor name so it is not reached by accident. One entry is the exception even to the wildcard: project_secrets.claude-oauth-token is also the default account's list in the published policy, and that list is exact, so fugaro project policy publish refuses a wildcard there rather than widening what a shared job's runs may use.

Inside a shared job there is no IAM boundary between repositories — the profile is the boundary. Today each repository still has its own job and its own service account, and a project secret is mounted on that job only for a name the repository may use (a reserved secret; a model provider's key per its own allow_data_to; a named Claude account's token per its own allow_data_to). Once shared project jobs exist, one job carries runs for several repositories: the boundary between them is then the profile the run is admitted under, not an IAM grant, and a project secret in that job's environment is readable by every one of those runs. On a repository switched on to the credentials service — its own root re-applied with the switch on — a per-repository job's model credentials are not in the job at all — its accessors and its mounts are dropped together, and the service attaches the credential to the call (see "The credentials service" below). That reaches per-repository jobs only: a shared project job is the exception, and the service refuses a caller that is a shared project job's account, so a cascade workflow cannot be served on one in this release. Either way this narrows the model credentials alone: the git credential, the workflow's own secrets and everything else this section names are unchanged.

A repository's own copy of the same name always wins over the project copy, until the copy's last enabled version is disabled — so passing --repo is how a value stays this repository's.

The data-policy gate is unchanged. A model provider's allow_data_to still decides, at runtime and before any call, which repositories' code the gateway may send to that provider; a project-scope key mounted in a job is not permission to route another repository's code anywhere. A named Claude account's allow_data_to is likewise the only gate on its token until phase 5 replaces that fixture with person-owned secrets. And workflows.<n>.secrets can be shared this way, through the operator's own workflow_secrets.<name> entry: a workflow's own secret that no such entry names stays a repository copy. A shared project job never mounts one at all — its secret_env carries the platform's reserved names and a configured provider's key, and a workflow's own name is neither — so the shared copy is mounted on the repositories' own jobs alone.

A workflow secret's entry is where its reach is decided. workflow_secrets.<name>.allow_data_to names the repositories whose jobs may mount a project copy of a workflow secret, so the reach of a name like db-password is that entry and not the whole installation — and the entry is the operator's own local config, which the shared config never carries. With no entry, or with an entry that lists nothing, the name mounts nowhere and stays a repository copy. A wildcard inside a segment (myworkspace/*) means every repository it matches now and later: a repository onboarded afterwards mounts the value with nothing else to do, and nobody is asked again. The value is whatever the repository's build and tests read, not a credential of Fugaro's own, which is why this block is the one place an application secret is stored once for several repositories. A repository that stores its own copy keeps it, as every other name does.

Reserved secret names

A name the platform reads a credential from is reserved, and no other document may have a value stored under it. Four names are mounts Fugaro itself builds into a job's environment — bitbucket-token, github-app-key, claude-oauth-token and anthropic-api-key (internal/config's ReservedSecrets, name → variable) — and two more families are reserved for a credential one component of the platform reads:

  • a model provider's own key, named after its entry in the local config (openrouter → openrouter-api-key, the convention docs/multi-model.md §2 spells out). The conventional name belongs to that provider whether or not its secret: field is written that way today, because a rename to the convention is exactly what would make a value stored under it the provider's credential: fugaro secrets set refuses another provider's — or any workflow's — value under it. A conventional name that is already one of the platform's own (an entry named anthropic gives anthropic-api-key, the direct Anthropic key) stays the platform's, and no warning advises it;
  • the harness OAuth names, claude-oauth-token (the harness Fugaro drives, with its claude-oauth-token-<account> namespace for named accounts) and codex-oauth-token (Codex, which Fugaro does not drive yet: nothing mounts one, and the name is reserved now so it cannot be captured before the harness lands), plus the codex-oauth-token-<name> namespace behind it.

The capture this closes is the same in every case: a value stored by a workflow or another document under a name the platform reserves would become a real credential — mounted into jobs, sent to a provider, or read by a harness — the day a rename or a new feature made that name live. fugaro validate refuses a fugaro.yaml that declares a reserved secret or a harness OAuth name, and refuses a local config's providers entry that claims another entry's conventional name, while warning (never failing) about a provider key written off convention; fugaro secrets set refuses to store a value under any of them, in either scope, before the value is read; fugaro setup's secrets stage neither prompts for one nor mounts one. An existing key named off convention keeps working and is never renamed or migrated — the reservation is about the name, and it holds from the day it is enforced.

The assumption

The design assumes you trust the repository and the source of the task. Everyone who can push to the repository, everyone who can launch a run, and (for follow-up runs) the people listed in followup.trusted can steer an agent that holds the credentials below. Fugaro is built for a team running it on its own code. It is not a sandbox for code you would not run on a developer's laptop.

A run may also be given files, and may also be asked for one. An artifact run (docs/design/artifact-runs.md) has no git token and no pull request: it is handed files the launcher attached, and it leaves files in the runs bucket instead. Two consequences follow, and neither is a sandbox. In: an attached file is trusted exactly as much as the task text, since the agent reads it and can act on what is in it — a screenshot can contain a token, a customer's name or someone's face, and an operator who attaches a file they did not write has handed the agent that content as an instruction. Out: an artifact is output of the same unsandboxed agent, written to runs/<slug>/<run>/artifacts/ under this repository's job account — the account a run of this repository runs as; there is no per-run account — so every launcher and every operator of the installation can read it (a launcher holds objectViewer on the whole bucket, an operator objectAdmin), every run of the same repository can read and overwrite it (the account is scoped to the repository's prefix, not to one run), and it is deleted with the rest of the run's record after 90 days by default. Nothing about an artifact is trusted: fugaro artifacts get verifies the sha256 recorded by the run, refuses a mismatch, never opens, executes or unpacks what it writes, and downloads into a fresh directory it creates (never .) — so a crafted name in a tampered record cannot land a .git/hooks/ or .claude/ file in a checkout you are working in. And what produces it is repository text read once, which is not a boundary against the agent: commands.render and commands.probe are the repository's own commands, kept in the runner's in-memory workflow configuration rather than re-read from a file the run itself can write, so the command string that ran is the string a reviewer read at the task's ref — but the plan stage and the render step are the same container, the same user and the same environment, so a render command that names a file in the checkout runs that file's working-tree bytes, which the plan stage could have rewritten, no more and no less than every other file in a checkout the agent controls.

That hash is integrity in transit, not tamper evidence: it and the artifact live in the same repository-scoped prefix, so anything that can rewrite one can rewrite the other — including another run of the same repository. Tampering is not detectable today; what is guaranteed is that the bytes you receive are the bytes the run uploaded.

The render step runs with the run's own environment, secrets included. commands.render executes as the agent's user with the agent's environment, so a workflow's secrets are visible to it — that is deliberate and is why an artifact recipe must not be handed to an untrusted repository. The runner builds the workflow's secret names into that environment only when !r.review (the secretEnvs block in internal/runner/runner.go), hands the result to r.env via agent.BuildEnv, and runRender in internal/runner/artifact.go starts the command with Env: r.env. It also means a render command can write a secret into a deliverable, and the deliverable is readable by everyone the paragraph above names.

Today that "everyone" stops at this repository, and that boundary is a precondition, not a property of artifacts. An artifact run's account is the repository's job account, so only runs of this repository can read or overwrite its deliverables. Project-centric phase 3's shared project jobs and the VM backend's accounts are not per-repository: on either of them the same run's inputs, artifacts and record would be readable and overwritable by every run on the profile. Because that blast radius has not been reviewed, an artifact-producing run stays on the plain per-repository Cloud Run backend until a per-run prefix condition or a per-run account exists (docs/design/artifact-runs.md §8.5, D11). No code in this slice enforces that, and this document does not claim it does: it is a decision about when artifact runs may be enabled on those backends, and the plan that does so must bring the per-run boundary with it. Until then an artifact run is only ever launched on the per-repository Cloud Run backend this installation uses today.

What limits the blast radius today

  • One container per run, discarded at the end. Nothing in the container itself persists between runs. What does persist is in the runs bucket by design: a cache a later run restores (cache/<slug>/) and a session a follow-up may resume (see below). The agent runs as an unprivileged user with no sudo and no setuid escape (the image self-test checks this on every build).
  • Per-repository identities. Each repository's jobs and builds run as their own service accounts. A job can read only the secrets its own workflow declares (not every secret the repository has), and it can read and write only its own repository's prefixes in the runs bucket. On a repository whose root has been re-applied with the switch on, a per-repository job reads no model credential at all: it declares none, mounts none, and its service account holds no grant on one (see "The credentials service" below). That scoping is the job account's alone; it does not mean the bucket itself is isolated between repositories or between launchers — see "What is not on by default" below. A project job is not one of these per-repository identities. Its service account is the profile's, it mounts the profile's own secrets rather than one workflow's list, and on a profile that lists Claude accounts that includes the account's OAuth token — which every run of the job holds, from the start, whatever the run's own configuration says — see "A project job holds its Claude account's OAuth token" below.
  • Cloud keys stay in the cloud. No command copies a secret value back to your machine: fugaro secrets never prints one and nothing writes one to disk. A few commands do read one into memory on the machine, each only where its work cannot be done without it. Two are yours, run at your own terminal and announced in one line before the read: the GitHub App pre-check (--check-github-app, which fugaro setup and fugaro doctor can ask for, and --github-app-key, which fugaro project sync takes) reads the App's private key to sign one JWT, and fugaro image build <entry> reads the credential it copies a catalog entry's Dockerfile with — the repository's github-app-key secret on GitHub, the project's bitbucket-token secret on Bitbucket — and only when your environment already holds none of its own. The rest read the credentials service's bundle document, on an installation that runs the service: fugaro creds set, fugaro creds ls and fugaro doctor --check-bitbucket-token are the commands that maintain and report on it. No value any of them reads is printed, logged, put in an error or written to disk, and none of the reads happens in a coding agent's session: they are refused there, because the value must never reach an agent's process.
  • With the budget gateway on, model keys stay out of the agent's environment. With budget observe or enforce and auth: api-key, or vertex under observe (Vertex under enforce is refused outright, see "What is not on by default" below), the agent's calls go through a per-run gateway: with api-key it gets a gateway token instead of the real key, and on Vertex the gateway fetches the job's own token instead. Either way the gateway pins the models and charges every call against the caps. This is off by default. With the credentials service on, the keys are out of the job entirely, not only out of the agent's environment: a cascade workflow's gateway carries each call to the service, which attaches the credential there (see "The credentials service" below).
  • Narrow git credentials. On GitHub, a run gets an installation token for its one repository (contents and pull requests write, issues and metadata read) that expires within about an hour. The App has no workflows permission, so a run cannot change .github/workflows/. On Bitbucket, the default is a repository access token, scoped to that one repository by Bitbucket's own design. A project-wide git credential is not narrow. fugaro secrets set bitbucket-token --project-wide (or github-app-key) stores one workspace or project access token, or one App key, for the whole installation — and, since project scope became the default for the App key (see "Project scope is the default for a name that has one" above), github-app-key does so with no flag at all. bitbucket-token needs an explicit project_secrets.bitbucket-token allow-list to be stored that way — one entry may name a whole workspace, myworkspace/*, so a repository created there later mounts it with no new grant (see "Project scope is the default for a name that has one" above) — and its per-repository default is why: a repository access token serves the one repository it was issued for, while the workspace or project access token an operator opts in to serves every repository the allow-list admits. What that token reaches is wider than the allow-list. The allow-list decides which jobs mount it; the token itself reaches everything its scopes reach. Every allow-listed run's agent holds the token's value, and so does every launcher of an allow-listed repository, and launchers are trusted as before: a malicious launcher can use the agent to write across the token's reach. Through pushes to another repository's base or Fugaro branches, an agent also reaches every other credential that repository's runs and builds hold. A hostile agent or launcher can push, open and comment on pull requests, and trigger Pipelines in every repository the token reaches, including repositories not enrolled in Fugaro; the App key mints tokens for every repository the App is installed on — not only the repository whose job or build holds it. Use it only where that reach is what you mean.
  • Logs are isolated in their own log bucket, readable by the people you made launchers, and redacted of every mounted secret, including common encodings. Redaction is best effort. Project Owners and Editors can still read every log, isolated or not. Cloud Build's own logs are never moved there: they carry a repository's build output, which by design never includes a secret value. fugaro setup --no-log-isolation leaves Fugaro's own logs in _Default too.

The GitHub App's key, when the credentials service holds it

A credentials service (docs/credentials-service/) is a separately deployed Cloud Run service that holds credentials a job would otherwise mount, and answers a run for them; fugaro update deploys it on every installation (the owner's ruling of 2026-11-05), and a repository's runs reach it once its own root has been re-applied with the switch on. The GitHub App's private key is one of them (D145). This section states the GitHub half — the App key, the minted installation token and the job's copy of the key — and nothing about the model credentials, which are the service's other half.

  • Signing is the service's, not the job's, from the release that tells a job where the service is. The App's key is one entry of the service's bundle secret, written by the owner with fugaro creds set github-app-key, and the service is the only Fugaro runtime identity that reads that secret. The keyless half is in the runner, and it is the job's own environment that switches it on: internal/gitprov/providers/providers.go opens the github provider without a key only when FUGARO_CREDENTIALS_SERVICE_URL is set, which is the "this installation has a credentials service" fact (D146/D147) — so no code path in the job mints a token or reads the App key when the job's environment names the service. A launch of a repository whose root has been re-applied with the switch on now sets it, on that repository's own job and on no other: fugaro run reads the installation's own record of the service — the URL the credentials stage wrote into the project's config, which the published shared config carries to every machine of the installation (internal/cli/run.go's credentialsServiceLaunchEnv, internal/localcfg) — and puts it on the execution's plain environment. So on a switched-on repository a run's installation token is minted by the service and the run's runner opens its git provider with no key at all; the bundle's copy of the key is the one that signs, and a bundle that holds none is a named refusal rather than a fall back to the job's copy (D143). A shared project job is deliberately not told, because the service refuses a caller whose account belongs to one — see "The credentials service" below — and its own credentials are unchanged until C3.9 moves them. On a repository whose root has not been re-applied with the switch on, no variable is set and the job reads the App key and mints in-job exactly as it did before the service existed. A token that cannot be minted is a refusal — the run fails at bootstrap and never falls back to a credential of its own.
  • What reaches the agent is its own run-scoped token, and its reach is unchanged. The mint signs with the same permission set the in-job mint uses (github.RunTokenPermissions: contents and pull requests write, issues and metadata read, no workflows permission), and the agent still gets that token as its git credential. Nothing here narrows or widens what a run may do on GitHub; what changes is where the signing key lives.
  • What bounds the mint is the run's identity, not a field in the request. A mint call carries the App's ID and nothing else — there is no repository field to fill in. The repository is the one the calling job's account is bound to, the caller's Google ID token is checked against the installation's job service accounts, and the service honours a run identity only from the job whose repository that identity belongs to. So a run of one repository has nowhere to ask for another repository's token.
  • The job's copy of the key is still in the job, and the runner no longer reads it. The mount and the runner's fetch ship as one change on purpose: a container that declares a secret its account may not read is a job Cloud Run refuses to create, so taking the accessor without taking the mount would break every GitHub run of a switched-on repository. Both are gated on one list in the code (config.ServiceHeldSecretNames, mirrored into the workflow module's service_held_secret_names and held to it by a Terraform guard test), and on this tree that list is empty — the flip is held until the owner's live Check 96 has proven the deployed service mints — so a switched-on repository still grants its job's account its accessor on github-app-key and still mounts FUGARO_GITHUB_APP_PRIVATE_KEY in the container: the residual in "What one run's agent can reach" below. What this release does change is who signs. The launch names the service (the bullet above), so the provider is opened keyless and the installation token is minted at the service, while the key the container still declares is one no code path in the job reads. This document claims the job no longer reads the key, not that the key is out of the job: the accessor and the mount leave in the release that flips that list, after the live check.
  • Every act on the answer path is audited, and no value is written anywhere else. One line per act, written before the answer goes out, and the line carries names — a credential's name, never its value — because the code that holds the values is the code that scrubs them (internal/credsvc/probe.go's NewProbeAuditor, over the bundle loader). One act carries two lines, and it is the model call: the issuance is written before the answer goes out — that ordering is fail-closed and it is why a stream cut in half would otherwise read as served — so when the stream then fails the outcome is written after it (internal/credsvc/cascade.go's answer, state_unavailable, the credential that answered and its tier), and the operator reads a call that got no whole answer rather than one that was served. That second line refuses nothing by then — the credential is spent and the answer has left — so a line that cannot be written is the service's own log line (s.log.Error) and not a refusal. The acts: a model call served (internal/credsvc/cascade.go, one line per issuance — an issuance whose line cannot be written is withheld, answerUnaudited), a mint issued or refused (internal/credsvc/github.go's auditMint and refuseMint; a mint's line names the App key and carries no tier), and every refusal answered once the caller's identity is verified — the refusals answered before the mint is reached included: a call refused because the run identity is not a run of the calling job's repository, and a call refused by a kill switch, are both answered by refuseCall (internal/credsvc/cascade.go), which writes the line through auditCall and then the typed refusal, and the line's status is the refusal's own wire error type. What carries no line is the refusals answered before there is a verified identity to record: an unknown route or method, a request whose headers cannot be read, and the identity check itself (internal/credsvc/server.go's refuseIdentity), all of which write the answer and nothing else (refuse). The mint's answer carries the token and nothing else, and no error, log line or record carries it either. That is the same rule the rest of this document's credential handling follows.

What is not on by default

Three gaps are easy to miss, because nothing warns you about them:

  • Budget mode defaults to off. With no budget: block in the installation's own config (the project ceiling fugaro setup writes), or an empty mode, there is no gateway and no per-call cap: the real model key sits in the agent's environment like any other secret, and the only limits are the provider account's own limit and the token cap, if you set one (0 is none). A repository's own fugaro.yaml can only tighten that ceiling, never loosen it.
  • Vertex has no dollar cap, in any mode. auth: vertex with budget enforce is refused at fugaro validate/update --repo, and again by the runner before the agent starts, so a Vertex repository can only run observe or off in practice. There is also no key to put a credit limit on: the job's Vertex calls are authenticated by the job's own service account through the metadata server, not a key you hold.
  • Every launcher can write runs/, for every repository, even after the 0.7.0 bucket IAM hardening. Before 0.7.0, or on an installation whose operator has not yet run fugaro update to apply 0.7.0's migration, every launcher — not only operators — holds roles/storage.objectAdmin on the entire runs bucket, unconditioned, including the installation-wide project layer, recipes and shared config: a launcher can rewrite the project layer's commands or add an image.setup that runs in the next rebuild, replace a recipe or the shared config, or poison another repository's cache — all without launching a run, reaching that repository's secrets through its next build or job. Project Owners, Editors and anyone with a project-level storage role bypass the bucket's own policy regardless, hardened or not (docs/design/bucket-iam.md §9). Once the hardening is applied, a launcher's grant narrows to roles/storage.objectViewer (the whole bucket, unchanged) plus roles/storage.objectUser on runs/, tokens/ and issues/ only (tokens/ since the budget token moved there, docs/design/budget-token-channel.md §7.2; the sealed token objects a launcher writes, replaces on a retry and removes after a launch that did not start; and issues/ since the shared issue store moved there, docs/troubleshoot-issue/reconciliation.md D1727, where the issue a person files is the launcher's own write) — but runs/ and tokens/ hold every repository's runs and queued tokens, not only the ones a launcher was onboarded to, so a launcher can still delete or replace another repository's queued token object (its run then refuses to start; a launcher can mint any repository's budget identity anyway, see below), and can still (docs/design/bucket-iam.md §10, item L3): overwrite another launcher's queued task.json, forge result.json or launch.json, set the cancel marker on anyone's run, or plant a session/ file or followup.md that a later follow-up of that repository restores (see "A follow-up's protections are hygiene, not boundaries" below). What a launcher can no longer do, once the hardening is applied: write the project layer, the shared config, recipes or the marker; rewrite a build record, check.json or a layer copy; poison cache/; forge or steal locks/. The fugaroLauncher custom role, which every launcher also holds (a separate grant from the bucket one above), additionally lets any launcher list every secret in the project (names, labels and version counts; reading a value still needs secretAccessor, which only the job and build accounts hold).

The credentials service

Deployed for every installation, and reached only where the switch is on (the owner's ruling of 2026-11-05; DON1 and DON8). The service is a Cloud Run service in the installation root, deployed separately from every job, with a service account of its own. fugaro update deploys it on every installation — there is no flag and no separate command, because fugaro update is the whole upgrade — and the run that deploys it records the service's URL in the project's config. Deploying it is not the same as reaching it, and the deploy alone changes no run: the service is idle until the installation's bundle holds a model credential and that repository's own root has been re-applied with the switch on, which is the apply that drops its jobs' model mounts and grants their accounts an invoker on the service. A repository whose agent.auth is not cascade is never switched on: the service has no route that would serve it. On a switched-on repository, a cascade workflow's calls go job → gateway → service → upstream — every launch of a per-repository job of such a repository hands it the service's address, so the gateway's upstream is the service, which attaches the credential there (D143). A shared project job is not given the address and is not served: the service refuses a caller whose account belongs to one (Global Constraint 6), and that job keeps mounting its provider keys and its Claude account's token with its grants — moving the shared jobs over is C3.9, and this release does not claim it. And the model credentials' other half is not in this release either: what a cascade run has is the service, while a repository job in another agent.auth mode has no model credential at all (the drop is auth-blind, only the cascade's calls reach the service) — the first bullet under "the limits this release does not hide" below states it.

It is the only Fugaro runtime identity with access to model credentials (D148). The others are named here so the sentence is not read wider than it is: the build service account holds secretAccessor on the git secret and on the workflow build secrets; operators hold the roles that add and list secret versions, which is metadata and not a value; and Project Owners bypass any secret-level policy and can read any secret in the project. An Editor is not one of them: roles/editor carries secretmanager.secrets.getIamPolicy and delete but not secretmanager.versions.access, so an Editor reads a secret directly, only through its own secretAccessor grant; indirectly, by carrying iam.serviceAccounts.actAs and run.services.update, which let it deploy code that runs as an account holding one — the same kind of grant the job and build accounts hold, which fugaro doctor reports per container. The service is the only Fugaro runtime identity with a model credential — it is not the only identity with access to Secret Manager, and this document does not claim otherwise.

One secret, one grant, one leak (Q6). Every credential the service can issue lives in one Secret Manager secret — a YAML document with a strict schema (an unknown key and an oversize document are both refused), read at latest with a short cache — and the service's service account holds exactly one secretmanager.secretAccessor grant, on that one secret — its only grant on Secret Manager, and the only one it holds on a credential: no grant per credential, and no name-prefix condition over the model secrets. The cost is stated with the shape: a read of the bundle is every credential the service holds at once, which is the same set compromising the service is — the single point of compromise and of availability the owner accepted as D143 — and not one key of it. The bundle is written by fugaro creds set, listed by fugaro creds ls, and never printed by either.

Its database reach is two permissions, and no instance lifecycle (D6). The service's second — and only other — grant is a role of its own on the installation's realtime database, and it is written rather than borrowed because Realtime Database IAM has no path-level scope: a binding is per instance and reaches the whole database, so the only question a role can answer is which operations the account may make. The operations the service makes are reads of a run's write-once outcome (outcomes/$day/$slug/$run — the first database read of a call, made by the identity check before the stop flags are read), of the operator's stop switches (config/kill/global, config/kill/repos/$slug), of the published policy (config/policy/shared and config/policy/repos/$slug) and of the credential state another revision wrote under creds/, and writes of its own creds/ nodes — a credential's mark, a run's lease and a probe's lease — each by compare-and-set (PutIfMatch, which is why the write permission is needed at all and not only the read). That surface is exactly the database's two data permissions — firebasedatabase.instances.get (read) and firebasedatabase.instances.update (write) — so the role is a custom one, fugaroCredentialsDatabase, declared in the Firebase project the database lives in, bound to that one account and to no other, with no condition, and deliberately neither predefined role: roles/firebasedatabase.viewer is get+list and cannot write a mark or a lease at all, and roles/firebasedatabase.admin adds the instance lifecycle (create, delete, disable, reenable, undelete) — the ability to delete the installation's database, held by the one identity that holds every model credential. This is the account's only project-level grant, and which project it lands in depends on the layout: in the default installation the Firebase project is the installation project, so the account holds projects/<installation>/roles/fugaroCredentialsDatabase there and nothing else in it; where the installation's firebase_project names a second project, the role and its binding live in that one, and the installation project holds no grant for the account at all. No condition is written on the binding because there is nothing a condition could fence: with no path to scope, one would be a fence that looks like one and is not.

What the two permissions are, rather than what the service uses them for. Realtime Database IAM has no path-level scope, so the binding that lets the service read a run's outcome, the stop switches and the policy is a binding that lets anything running as that account write every one of them too — config/kill/*, including the switch that is supposed to stop it, config/policy/*, including the allow-list that holds a call to a model, and outcomes/. The service writes none of those (its writes are the creds/ nodes above, built by internal/creds' own path builders and by no caller's string), but that is a fact about the code and not about the role: through IAM this account can read and write the whole installation database, so a revision that ran different code could in principle turn off its own stop switch or widen the policy it is checked against — and the same identity already holds every model credential a call can be given (the paragraph above), which is the class of reach that matters here. The fence is the service's own behaviour, and D6 accepted that rather than give the marks and the leases a second identity that would have to hold the credentials. What this role does not change: it reads no secret (the paragraph above is the whole of the service's reach on credentials), and it does not widen what a caller may ask for — the published policy and the account's own allow_repos still decide which credential a call may be given. An installation whose deployment predates 0.10.1 has no such binding, and its service refuses every call — the first database read of a call is the run's write-once outcome (outcomes/$day/$slug/$run, read by the identity check that runs before the stop-switch read), so the refusal is "the run's outcome could not be read" under state_unavailable, and a call that the identity check refuses first is never taken to the database at all. Running fugaro update once against that installation is what adds the binding, and until then its service refuses every call as it did in 0.10.0.

With the service on, a per-repository job holds no model credential at all. The job's service account has no secretAccessor on a model secret and its container mounts none; the two go together, because a mount left behind is a job Cloud Run refuses to create. The service attaches the real credential to a call the gateway forwards — the half that is not wired on this release (see the limits below) — and the value never reaches the job: not the agent's environment, not the runner's /proc, and not a log line — the service's own audit lines carry names and never values. The drop is installation-wide and per repository, and a shared project job is the one job it does not reach: the phase-3 module carries no switch wiring at all, so a shared project job keeps its model-secret accessors and keeps the Claude account token its profile mounts (see "A project job holds its Claude account's OAuth token" below) and gets no invoker grant on the service — and since the service refuses a caller that is a shared project job's account, a cascade workflow cannot be served there. The drop is also not per workflow: it is a function of the repository's switch, so a switched-on repository mounts no model credential in any of its jobs — the Claude OAuth token, the Anthropic key, or a provider's own key.

The invariant. The rule the code enforces, and a test holds it, is that an OAuth token is only ever sent to Anthropic's own endpoint: checked on the route and on the destination the request will actually go to, and a test fails if any route carrying a provider base URL can also carry an OAuth credential. A cascade run's tier A is one of those tokens, so this holds for the service's calls exactly as it does for a job's.

Unreachable halts, never falls back. Once a run has an upstream to call, a service that is down, unreadable or refuses halts the run named rather than letting it reach for a credential of its own (D143): the runner refuses the run where the reason is known rather than falling back to an in-job key, and there is no fallback path in the gateway either. What makes that safe is the mount, and the mount is the switch's, not the outage's: a per-repository job is the job the switch reaches, so a cascade workflow there has no model credential mounted to reach for whether the service answers or not. A shared project job, which keeps its mounts (above), is not served by the service at all — the service refuses a shared job's account as a caller (C3.9's shared-job rules are not on main), and a cascade workflow with no service to call is refused at the gateway rather than answered from the job's own token.

The operator's stop flag. The service honours the kill switch Fugaro already has: with the project's switch or a repository's on — fugaro budget kill --all, or fugaro budget kill --repo <owner/name> — it issues no credential at all and answers a typed refusal the run halts on. The switch is read before the bundle, so a stopped service reads no credential, and an unreadable switch is a refusal rather than an off switch. fugaro budget resume clears it. docs/credentials-service/runbook.md is the operator's side of all of this.

What ends a run's identity, and what is left when nothing has. A call is admitted only for a caller that is an installation job service account — the ID token's signature checked against Google's own certificates, its email claim against the installation's job service accounts — and for a run identity this project issued and that has not ended. Liveness is read from the write-once outcome, outcomes/$day/$slug/$run, which the database's rules let only the run's own end write, and never from the registry entry the run's own token may rewrite. Until that outcome exists, the identity's own fx claim (at most 30 days past the mint) is the only bound on a replayed identity: a captured call is replayable until the run ends or its fx passes, and nothing narrows that further today.

The service's own cap check is ADVISORY. The service may read a cap and refuse a call over it, but the binding cap stays the in-job gateway's ledger (D143): a per-run figure the service got wrong is not a security boundary, nothing relies on it, and a run's real spend is bounded where it always was — by the gateway's caps, by the token cap, and by the provider account's own limit (see "Money" below).

Accepted limits, recorded rather than closed (the owner's answers of 2026-10-19). The launcher's reach: a launcher writes the task text of every run it starts, and any launcher can mint a budget identity for any repository (see "Any launcher can mint a budget identity for any repository" below), so a launcher that may start a run the service admits can steer what that run does with the credential the service hands it. The replay bounds: the paragraph above — fx is the only bound until the write-once outcome is there. Both are limits this release states and does not mitigate; the budget token's seal narrows the first for peer reads and closes neither.

Egress is unchanged (D144). A run can still reach any host (see "Network egress is not restricted" below). What the service changes is the size of what egress can take from a job: one run's short-lived identity and the workflow's own secrets, rather than the installation's model credentials. A VPC with egress rules is a later hardening and is not built here.

And the limits this release does not hide. Three things a reader would otherwise take from this section, said plainly rather than left to be assumed:

  • the service is wired as of 0.10.1, and the half it does not carry is the other auth modes'. fugaro update deploys the service and this release gives a per-repository job its address — fugaro run sets FUGARO_CREDENTIALS_SERVICE_URL on the execution's environment, which is the one name the runner's cascade upstream and the keyless GitHub path both read — so a cascade run's calls reach the service and are served by it, and a GitHub run's installation token is minted there. What is not wired is every other agent.auth: the mount drop is auth-blind (an oauth or api-key repository job of a switched-on repository declares no model credential), while only a cascade run's calls go through the service — so those workflows have no model credential of their own and nothing yet to replace it, and a repository is switched on only when its workflows run agent.auth: cascade. A shared project job is not wired either (the service refuses its account; C3.9 moves it), and a repository whose root has not been re-applied with the switch on is byte for byte what it was.
  • the GitHub App key does not move off the job in this release. The one switch that would take its grant and its mount away is config.ServiceHeldSecretNames, and it is empty until the release after the owner's live Check 96 proves the deployed service mints. So with the service on, a job still mounts github-app-key and still holds secretAccessor on it, exactly as before — while the runner, told the service's address, no longer reads the key and mints at the service instead. The grant that survives is a key no code path in the job reads; it is taken away by the flip, not by this release.
  • the launcher makes no account allow-list check of its own. A cascade launch is refused for an installation with no database, for a project policy that does not carry the switch, for a budget whose mode leaves the gateway off, for a project policy that publishes no budget.allowed_models (the service refuses every call of a project that publishes none, so the run's first call would be refused and halt it), and for a database whose rules predate the credentials family — and it is not refused for a repository no admitted account covers. Which credential a call may be given is the service's decision: the intersection of the published policy node, the repository's own published child and the account's own allow_repos, read at the moment a credential is chosen.

The hosted MCP front door, and what its audit does not record

Two events leave one structured line — a token this door refused, and a tools/call naming a tool of this installation, whatever the door answered — and the records live in a store of their own. The token records, the client registrations and the audit are objects in a bucket of the installation's own — fugaro-mcpauth-<project>, whose IAM names the front door's account mcp_server and no other member — and never in the project's Realtime Database, where the mcpauth node stays in the rules as a second barrier with .read: false and .write: false (D935). So the store is reached by the service and not by a person, a launcher, an operator or a budget admin, and a reader of the record is one of the two doors below rather than a reader of the bucket. That store and its one member are the design's and not an installation's yet: no Terraform creates the bucket or its binding — the file that will is M4's store half, deploy/terraform/gcp/modules/installation/mcpauth_store.tf, and coordinator.tf says in place that the binding does not exist — and the front door is off by default with no binary linking this package, so what the tree carries is the code's own way of writing records into such a store, and what it does not carry is the bucket whose IAM can name exactly one member. The line names the identity the token resolved to, the tool, the scopes the token carried, the outcome and, when the door refused, why — every field a value out of a closed set or a name, never a token, a code or a credential (ee/coordinator/mcpauth/audit.go) — and it is written before the answer goes out, with the sink asked whether it records at all before any tool is handed the call: the line names the outcome and so cannot be written before the tool runs, so a sink that records nothing is found out while a deletion is still only a request. A call this door cannot audit is not answered — the same fail-closed rule the credentials service follows (D143) — and a handler that fails while writing the line is the one fault no check can see coming: there the act has been made, and the caller is told the door could not record it. One person's calls also come to a bounded per-identity record in that store, at most 64 entries and newest first, which is what an operator reads through the get_audit_record tool; a launcher is refused that tool, and no caller of any kind reaches the objects (D933). Revocation is one delete of a token family, through POST /oauth/revoke or the revoke_family tool, and it is the same delete either way (D930).

What the audit does not record, said plainly rather than left to be assumed:

  • The per-identity record is a view, and a caller can flush it. It is bounded and it is per-identity, so 64 calls by one person erase that person's own oldest entries — the Cloud Logging line is the durable copy and the record is the readable view (the owner's answer of 2026-11-04). It is the line that has to be written for the call to be answered; a line lost after that — a sink that routes nowhere, a retention that expires — is evidence this door cannot know it lost. The record never holds another person's calls, so a caller can only flood its own.
  • A refusal is audited once a token, a tool or a call is what was refused. A refusal of the credential itself — no bearer token, one this installation never minted, one whose family was revoked, one that has expired or is of the wrong kind, an identity on neither of the installation's lists — is one line, and so is the refusal of a tools/call that named a tool of this installation: a scope the token does not hold, arguments the tool cannot read, a name the catalogue holds that nothing has registered yet (D936's spawn_task and cancel_task are exactly that), or the tool's own refusal. What carries no line is a request this door refuses before it has a tool of its own to name: a body that is not one JSON-RPC request, a method it does not serve, a tools/call naming no tool at all, and a tools/call naming a name the catalogue does not hold — a misspelling, or a name some other build of this front door serves. Each of those is answered and recorded nowhere: the door's logger line (finding M-e) is the line of a credential or a scope refusal, and none of them named one. The audit's unit is a call on a tool of this installation, and an event with no such tool is not one.
  • A request this door answers without naming a tool leaves no line, and neither does a notification. initialize, ping and tools/list are answered to a token this door admitted and are recorded nowhere, and a notification — a request with no id, which is what MCP's own notifications/initialized is — is answered with no body at all (JSON-RPC 2.0 §4.1) and leaves no line either. A client that connects, lists the tools and polls ping for a day is therefore absent from the audit: the only line such a request can leave is a refusal of the token it presented, and nothing is written about the ones this door admitted.
  • The identity in a line is what the token resolved to, and it is not proof of who was there. H1 is accepted and documented rather than closed: any launcher can mint a device token and sign in as an operator's device at the approve step (the amendment of 2026-11-04), so a line is evidence of which token was used and not of who used it. The change that would close it — a device-held signing key — is not built.
  • Revoking a family ends what that family minted, and not a person's whole access. A person holds one family per registration they approved, and each is ended on its own; the audit record names the family a call came from, which is what makes ending the right one possible, and it keeps naming it after the revocation, because the record is a record of what happened.

Shared images: what the key protects, and what it does not

internal/imagecheck computes the key a shared dependency-layer image is built and reused under (the design is docs/forge-authorship-images/shared-images.md: the key, the superset lookup and the lock are built, and the refresh, the job update and image ls are not). The key is a hash of the packages' names, versions and the integrity or checksum each key file states for that version's artifact, with the toolchain and the base, and the reduction refuses a package a file resolves from a source that is not the ecosystem's registry. What that answers is one repository's honest lockfile against another's: two repositories whose files resolve a version to different bytes get different keys and different images, and a package a file states no integrity for is keyed only when the file also says it came from the ecosystem's default registry — so a name and a version alone never stand in for an artifact.

It is not a boundary between the repositories that share an image. The files that decide what the install runs state no package versions, so they contribute no fact of the key: .npmrc and .yarnrc.yml name registries, .pnpmfile.cjs and a plugin under .yarn/plugins/ are code the package manager loads, a patch under .yarn/patches/ or one a pnpm patchedDependencies entry names rewrites a package's source before it is installed, a package.json scripts entry runs during the install, and a go.mod replace directive sends a module to another tree than the one its version names. A repository on a shared key can therefore change what the next build of that key installs for every repository already on it — the same class as the image.setup of the repository that built the key, which the others inherit (the design's §2.2) — and a hostile repository inside the installation the images are shared across is not something the key addresses. What bounds it is the assumption above: image sharing is a decision about repositories you already trust.

Two shapes key an empty integrity from configuration the file does not state. A pnpm packages entry written as a bare name@version key, with no resolution line, is keyed with an empty integrity: that format's convention is that such an entry came from the default registry, and pnpm writes no source for one. Which registry the install actually fetches it from is configured in .npmrc, which states no package version and contributes no fact of the key, so two repositories whose pnpm locks are that shape take one key even when one of them points its install at another registry. A requirements*.txt pin that carries no --hash is keyed the same way: pip's index is configuration too — pip.conf, PIP_INDEX_URL — and not a line of the file, so such a pin takes one key whichever index the install would read it from. The other empty integrities are read from a line in the file — the host of an npm resolved, a Cargo source, a uv or Poetry registry, a Pipfile entry's own index — and a source those lines name that is not the default registry is keyed only beside an integrity. A URL among those lines is read as a URL and not as text: the host is the one its authority names, so a source that carries the default registry's name in userinfo — https://registry.npmjs.org:x@evil.example/x.tgz — is evil.example's artifact and not the default registry's.

Known limits

These are real, current gaps. Each is a decision or a later hardening item, not an oversight.

  • What one run's agent can reach. On a first run the agent's environment holds:

    • the repository's git token (as a credential helper, FUGARO_GIT_TOKEN and GH_TOKEN);
    • the per-run gateway token;
    • every secret the workflow declares.

    It shares the container and its user with the runner, so it can also read the runner's environment through /proc. That includes:

    • the GitHub App's private key, which can mint tokens for every repository the App is installed on (use one App per repository if that matters to you) — unless the repository's own root has been re-applied with the switch on, where the key is the service's and the runner mints nothing in-job: that keyless path is switched on by the job's environment naming the service, and the launch of such a repository's job now does set it, so the runner reads no key and the installation token is minted at the service (see "The GitHub App's key, when the credentials service holds it" above). What is left is the key's own copy: it stays in the job — the account's accessor and the container's mount — until the release that flips the drop, and that release has not landed on this tree (the list the drop reads is empty), so a switched-on repository's job still mounts the key and the agent can still reach that mounted copy here, while no code path in the job reads it;
    • the real model and provider keys;
    • the job's service account, through the metadata server.

    On a repository whose root has been re-applied with the credentials service's switch on, the second item is empty for every workflow of a per-repository job, whatever its agent.auth: the switch removes the model mounts, not the workflow's auth — the drop is agent.auth-blind by construction, so an oauth or api-key workflow of such a repository mounts no model key for the agent to read either, and only a cascade run has the service attach one to each call (see "The credentials service" above). The git token, the App key, the gateway token, the workflow's secrets and the metadata server are all unchanged, and a shared project job is unchanged in every one of them, this item included.

    Commands run through fugaro verify see the agent's full environment.

  • A mixed-credential run holds the review account's OAuth token in the runner's environment, the same class as the provider key it already holds. Ruled 2026-11-04, with the rung that names the account widened on 2026-11-05: an api-key run whose coder is provider-served may declare one stage-scoped Claude OAuth account and run its review and adversarial stages on it, those two and no others — named by the stage's own key, or, where that stage's own model is an Anthropic one, by the project's default_claude_account (design/mixed-credentials.md §11: the rule, the threat model, and what a failed stage does). The rule is built end to end, and its tasks B1–B5 shipped in 0.19.0: the config rule and the launch (B1), the job's mount of the account's token (B2), the stage's own environment and the managed settings that follow it (B3), the account stage's notional accounting (B4) and the run's own text for the account's usage limit (B5). The mount is the runner's, because the runner is what gives a stage its environment, and the coder's own environment is an allow-list: internal/agent/env.go's BuildEnv keeps the base variables, the auth variables and the workflow's declared secrets, while the provider key stays in the runner where the gateway reads it, so the coder's stages' environment carries neither credential. That is the whole of the narrowing, and it does not narrow the /proc read: the agent shares the container and its user with the runner, so a prompt-injected coder reads the account's token from /proc/<pid>/environ exactly as it already reads the provider key, the git token and every declared secret — the same exposure class, not a new one, which is the ground the mix was accepted on. The token also becomes the job's service account's to read (secretAccessor on that account's secret, and no other account's) with the metadata server still reachable, so two routes reach it. What the review stage costs is not bounded the way the rest of the run is: its calls go direct to Anthropic and never through the gateway, so there is no dollar cap of the gateway's on the stages that use it, and the credential is account-wide, shared in its rate limits with the owner's own use of the account, and revocable at the account (see "Money" below, where the same limit is stated for auth: oauth). The one mitigation that would narrow all of it is a separate job execution for the review stage, with its own container and service account; it is not built (design/mixed-credentials.md §6.4, §11).

  • A run's commit author is the launcher's claim, and its trailers are a record. With git.author: launcher in fugaro.yaml a run's commits are authored by the name and address the launching machine's local config carries (the address is commit_email, else the identity that machine launches as; the name is commit_name, else its git config user.name), and nothing verifies either: any launcher may write any name there, and only the domain of the address is checked (internal/cli/run.go): an address that is neither the launcher's own nor at a forge's noreply domain is refused, while a colleague's noreply id and login pass, and the name is checked only for the shape git's header can hold — so a launcher can write a colleague's noreply address and name and have the forge link the run's commits to that colleague's account. Nothing is signed either. The committer stays the run's own identity. Read an author on such a commit as what a launcher said about the run, not as who they are. The Fugaro-Run, Fugaro-Rigor and Fugaro-Model trailers are written into the commit's message by a hook the runner installs, reading values it puts in the commit's environment, and a run's agent shares the container and its user with the runner (see "What one run's agent can reach" above): an agent can commit with the hook bypassed (-c core.hooksPath=…), unset the environment those values travel in, or push the run's branch itself with its own token, so a commit without the trailers is no evidence that no run made it and one carrying them is no proof that the run's own code made it. Finalize counts the run's commits that lack a Fugaro-Run line and logs the count; it never rewrites them.

  • A launcher may approve a pull request their own name is on. Fugaro opens the pull request as the bot account, and with git.author: launcher the commits under it are authored by the launcher, so the forge's rule against approving your own pull request does not see them: it compares the approver with the pull request's author, which is Fugaro. Nothing in Fugaro reads who approves a pull request, so a repository that needs a second person uses its forge's own review rules (code owners, required reviewers) rather than counting on this.

  • The managed settings file only guards against committed configuration, and only with the gateway on. With the gateway on, the runner writes it before every stage, with the highest precedence, so a repository's own .claude/settings.json can't reroute the agent's model calls by itself, and the runner refuses to start a stage if anything still routes around it. With budget off there is no managed file and no routing check at all, so a committed .claude/settings.json can send the agent's calls, and the key, anywhere. Even with the gateway on, the file is writable by the agent's own user — the runner's — so a compromised agent can edit it mid-stage, or read the gateway token and the real key from the runner's /proc directly, same as above.

  • A follow-up's configuration can be stale, but its working tree isn't. A follow-up reads fugaro.yaml, agent.instructions and the review prompt from the base branch, but Claude Code still loads CLAUDE.md, .claude/ (skills, settings, subagents) and .mcp.json from the pull request's own branch, so a hook or MCP server planted there runs with the job's full environment and secrets. Review a PR's diff with that in mind before launching a follow-up on it.

  • Any launcher can mint a budget identity for any repository. The signing account that mints a run's Firebase custom token holds no roles itself, but every launcher — not only the ones who onboarded that repository — holds signJwt on it, so a launcher credential is budget write access up to the caps for every repository, not only their own.

  • A launcher can override a job's environment, and the project's policy is read from it. Anyone who may launch may pass environment overrides to jobs.run — Check 49 step 6 (added by Task 1.7, owner-run) sets FUGARO_RUN on a live job to show exactly what that reaches — so a launcher can also clear or redirect the two variables that decide whether a run reads the project's policy at all: FUGARO_RTDB_URL, the pointer to the database, and FUGARO_FIREBASE_API_KEY. Pointed at another database, or cleared, the run has no policy to read, and since project-centric mode is the published node, what it does next depends on the job it runs in: a budget-on job stops, because its launcher minted it a budget token — it mints whenever it can see a published policy or a shared budget, whatever the run's own mode — and a run launched with a token whose job carries no budget backend to read the policy with refuses rather than falling back (the same refusal a mode-on job meets when the token object has been removed from the bucket); a budget-off job of a project that has published nothing has no token and no marker to refuse on, and runs on the ceiling its job was deployed with, the same ceiling an installation that has never published a policy runs on. The budget token deliberately does not travel that way: it is a bearer credential — a Firebase custom token minted per run, whose theft is the whole attack — so it is left in the runs bucket, sealed, and never passed as an environment value, because the request that starts a job reaches audit logs. What does travel that way is the seal key, and it is a different object: the launcher seals the token object under a 32-byte key it draws for that one launch (a --retry draws a new one and replaces the object) and hands the key to the one execution it starts as the value of the FUGARO_BUDGET_TOKEN marker. The key is not a credential: it opens one object, for one run, and grants nothing by itself. Its audience is the override's, and it is named rather than assumed: the project's readers — launchers, anyone the owner has given log read (the jobs.run request reaches the audit log), and, unverified until Check 52 step 3 and Check 60 step 3 record it, anyone with execution read (run.executions.get) — and not a peer run of the job, which holds the object and no Cloud Run permission. A principal who holds both halves — the object and an override view, such as an operator who reads the audit log — recovers the token; the seal is a capability against the peer run, not a boundary against the project's readers (docs/design/budget-token-channel.md §7.1). A run parses the marker strictly, never prints its value, registers the key with its redactor before anything else, and reads the object only through the key: a plaintext object, or any object with no key, is refused, never read. The launch's own agent is outside the seal, and reaches the key the same way it reaches the others above: the marker lives in the runner's process environment — the agent's own environment is built from an allow-list and does not carry it, and every git call strips it — but the agent runs as the same user in the same container, so it can read /proc/<pid>/environ for it and unseal its own run's token. That is the run's own budget identity, the one it already spends under, and never a peer's: what the seal removes is the peer run's theft, and this residual is the agent's own token, not another run's. It is named here rather than left implicit because the seal is a capability against the peer and not a boundary against the run itself. On the Batch backend, which has no per-execution override and could only carry the key in the job definition's plain variables — a standing copy every launcher (roles/batch.jobsEditor) and the reaper (batch.jobs.get) can read back with gcloud batch jobs describe — a sealed launch is refused rather than carried. The marker is itself an environment value a launcher may set or clear: a different key unseals nothing (the object was sealed under the key of the launch that won the claim), so overriding it can only make the run refuse; and a launcher who can write tokens/ can remove the token object the marker points at (see "Every launcher can write runs/" above). A launcher is also closer to choosing which repository a run is for than the run's own refusals suggest: it can set FUGARO_RUN, the run's storage slug, and anyone who can write runs/ can write the task.json of any run (see above), so the slug-and-task pair an admission is computed from is not beyond a launcher. What stops that today, on a phase-1 job, is the per-repository job's bucket condition: the job's service account holds its own slug's prefixes, so a FUGARO_RUN override naming another slug fails at the task read, before the admission check runs, and for the bucket condition's reason rather than the binding's. Check 49 step 6 is what records which of the two refusals appeared; the binding's own live proof is the phase-3 check on a project job, Check 53 step 6 (added by Task 3.7), where the admission check is the only thing that can refuse. The run's own refusals are what a project job relies on: the slug must be the slug of the repository the task names, a remote on another provider's host is refused, and the project's allow-list is matched against the provider kind derived from that slug, never against FUGARO_GIT_PROVIDER, which may only refuse. FUGARO_REMOTE is not tied to the task's repository, though: it is where a job whose image has no checkout clones from, and everything the remote gives away is compared with the task before anything is cloned, in the admission check the run makes before any git credential is requested (internal/runner's admissionCheck, reading the remote through remoteHost, remoteEndpoint and repoNamedByRemote). Refused there are: a remote on another provider's host; one on a host no provider kind owns — github.com and bitbucket.org are the two, so a self-hosted forge is refused — because the run scopes this repository's git credential to the remote's endpoint — scheme, host and port — with no allow-list of its own; one written over http (that credential in cleartext), or one naming a port on an https remote (that credential sent to an endpoint the provider does not serve); and one whose path names a repository other than this run's, a group or project path included — task.CanonicalRepo allows a nested path such as acme/group/app, so the path is compared with the task's own repository segment by segment and refused when it differs, rather than read as "no repository" — as is any path a repository name cannot hold (dot segments, an empty segment, an encoded separator, acme/app.git;x), which is a refusal rather than a skip because git may resolve such a path to a repository this run is not for. On a project job FUGARO_REMOTE is not read at all: the clone URL is computed from the task's repository, the job's provider and the policy (Task 3.3). On a per-repository job, what is not compared with the task at all is the remote that gives the comparison nothing to read: one naming no host — an empty FUGARO_REMOTE, a local path (/srv/git/app.git, ./app, file:///tmp/app), a bare github.com with no scheme — and one naming a host but no repository path (https://github.com/). And with FUGARO_GIT_PROVIDER also unset and the remote naming no host, the provider the run opens is the one the cloned repository's own fugaro.yaml names, which is not compared with the provider derived from the slug either; where the remote does name a host, the provider is that host's kind, and a git.provider that disagrees with it is refused (the token is scoped to its own host). This is an accepted decision, not an oversight: the launcher holds the job's own credentials, and this list's first items already say what a launcher can reach. The launcher-side refusals fugaro run makes (a repository the policy does not admit, a policy newer than the binary) are advisory for the same reason — they are there to tell an operator before a job fails, and the run makes its own.

  • A follow-up's protections are hygiene, not boundaries. Pull request comments sit inside a nonce-delimited block, but a model can still follow instructions inside it; the delimiter only keeps a legitimate review comment from being mistaken for one. A resumed session is replayed as the agent's own earlier turns, outside that block, so anyone who could write to a previous run's session file can plant context there: any run of the same repository (they share the job's service account and bucket prefix), and, since 0.7.0, any launcher of the installation at all — a launcher's runs/ write reaches every repository's session files, not only the ones they were onboarded to (see "Every launcher can write runs/" above).

  • The shared issue store's own rules are hygiene between honest clients, not a boundary. A launcher's grant reaches issues/ (see "Every launcher can write runs/" above), so any launcher can rewrite or delete any issue record, its number counter, its claim or the fingerprint index the store keeps, and "a submitted record is final", "one fingerprint gets one number" and "two submitters file once" hold only among clients that follow the protocol (docs/troubleshoot-issue/reconciliation.md D1727, finding F24). What is not a boundary is not a hole either: a public post is rebuilt, previewed and re-redacted by the submitter's own binary from the record that machine wrote and read, so a rewritten record cannot make anyone file text they did not see and approve.

  • A proxied network's credentials reach the agent. HTTP_PROXY and HTTPS_PROXY pass through verbatim so the agent works on proxied networks; NO_PROXY does too, except that with the gateway on the runner adds the loopback addresses to it. A proxy URL with user:password@ embedded is as readable to the agent as any other variable. Authenticate your proxy by network or identity, not a URL credential.

  • Network egress is not restricted. A run can reach any host. Prompt injection through the repository, a dependency, an issue or a PR comment could send source code or the credentials above anywhere. A VPC with egress rules is a later hardening option, not built.

  • The base branch is protected only by your branch protection. The runner pushes only to fugaro/<run-id> branches, but the agent holds a token with contents write and can run git push itself. Require reviews and status checks on your default branch: Fugaro relies on it.

  • The agent can open or edit pull requests itself with gh. Its instructions tell it not to; nothing technical stops it.

  • Money. The gateway's caps bound a run that goes through the gateway. An agent that reads the real key from /proc can spend outside it. The backstop is the provider account's own limit: set a credit limit on every key you give Fugaro (Anthropic workspace limits, OpenRouter credit limits; Vertex has none to set, see "What is not on by default" above). With auth: oauth there is no gateway, only the token cap and the kill switches. A run's budget identity can reserve, up to the caps, until it expires (job timeout plus about an hour), even after the run ended. A cascade run of a per-repository job has no model key in its /proc to read (see "The credentials service" above), so its calls all go through the gateway and the caps bound them — but the service's own cap check is advisory, not a second boundary: the binding cap is still the gateway's ledger, and a subscription tier has no dollar cap to set at all.

  • An artifact run's compute is not capped. The budget's caps count model dollars (docs/design/m9-spec-v3-reconciliation.md §7.4's reconciliation), and a render is compute: it is bounded by the run's own clock (timeouts.total, and a Cloud Run task timeout of total + 2m) and by the workflow's resources, never by budget.per_run_usd. result.json's cost.compute_usd is an estimate, reported and never charged. If you give an artifact workflow 8 vCPU, a two-hour total, and a command that never finishes, you pay for two hours of 8 vCPU and the caps will not stop it.

  • Runs of one repository can see each other, and the 0.7.0 bucket IAM work does not change that. They share a service account and its bucket prefixes, so a hostile run can read or overwrite another run's records and caches in the same repository. docs/design/bucket-iam.md is explicit that a compromised agent inside a run is unchanged by that work; it narrows what launchers can write (see "What is not on by default" above), not what a run's own job account can reach. A shared project job widens that from one repository to every repository in the runs bucket, not to a profile, and that regression is stated here. Phase 3's module (deploy/terraform/gcp/modules/project/, design docs/design/project-centric.md §4.1) creates one Cloud Run job and one job service account per (profile, provider, Claude account), and fugaro setup creates one for every profile a local config names. Such an account serves every repository admitted to its profile, so its runs-bucket grant cannot name one slug: the condition covers the whole of runs/, cache/ and locks/, and, read-only, the whole of tokens/ (roles/storage.objectViewer, never objectUser, so a project job can read a queued run's sealed token and neither delete nor replace it) — every project job account gets those same conditions, whatever its profile, and it reaches every repository's objects in the bucket, including repositories on other profiles and repositories that still run their own per-repository jobs. That is a loss of a property this document credits 0.7.0 with gaining (above: 0.7.0 removed "poison cache/" and "forge or steal locks/" from what a launcher can do). A run is an untrusted agent, unlike a launcher: a hostile one on a shared job can poison another repository's cache — cross-repository code execution on that repository's next run, since a cache it restores is code and configuration the run executes — forge or steal a lock, and rewrite another repository's queued task.json, which is what a run's task, ref and workflow are read from. Two more things the same prefixes reach, neither of which the design's accepted decision D4 listed on its own: another run's launch.json and result.json, which is why the runner checks the execution it was launched as (CheckRunExecution; plan Task 3.4) — and another queued run's budget token (tokens/<slug>/<run>/budget-token), which is the one object this no longer covers, because its channel is sealed (docs/design/budget-token-channel.md, both halves built for 0.9.0): the token object lives under tokens/, where a job's grant is read-only (roles/storage.objectViewer, so a run can neither delete nor replace a queued run's token), and it is sealed under a key only the execution it was minted for receives, so a run that reads another run's object — today a run of the same repository, and on a shared job a run of the same profile — reads ciphertext it cannot use. That is a capability, not a boundary: the peer can still read the bytes, and a peer that hands them to someone who also holds the key's audience (above: the project's readers) has handed over the token. The read-only take leaves the object in the bucket until the lifecycle rule removes it (a day), and the token inside is exchangeable for an hour after the mint, so a copy taken together with the execution's key stays usable for that hour. It is an accepted decision of its own, D13, ruled by the owner on 2026-10-15, whose open decisions D14 to D19 were answered the same day. A profile separates secrets, not bucket objects. Different profiles are different job service accounts, so a profile decides which secrets a run can read; it does not keep one repository's run away from another repository's runs/, cache/ or locks/ objects, because the object layout is <prefix>/<slug>/… with no profile in it and so no per-profile condition can be written. Putting repositories that must stay apart on different profiles therefore does not isolate them in the bucket. D4's and D13's rulings named separate profiles as the mitigation on the premise that a profile is a bucket boundary; that premise does not hold, and the owner ruled the question on 2026-10-17: the installation-wide reach is ACCEPTED, and this paragraph is the statement of it. Every shared project job's service account holds roles/storage.objectUser on the whole of runs/, cache/ and locks/, and roles/storage.objectViewer (read-only) on the whole of tokens/ — the same conditions for every profile's account, because a condition can name no slug and no profile — so a run on a shared job reaches every repository's objects under those three prefixes: every repository of its own profile, every repository of every other profile, and every repository still running its own per-repository job. It is accepted with the exposure above stated in full rather than with a mitigation claimed for it. What it is accepted for: one job account serves one profile's repositories, so a condition that named one slug would refuse every run of the profile but one, and the alternative — a profile segment in the object layout — is a breaking change to the path every installed CLI and job reads, and is a later change rather than this phase's. What bounds it: the reach is a run's, and a run is already the untrusted party here (this is one repository's job account's own reach — poison its own cache, forge its own lock, rewrite its own queued task — widened to the installation's repositories); an installation that names no profile in its config gets no project job, keeps the per-repository grants and is unaffected; and docs/design/bucket-iam.md §10 states the threat model, including the token exposure D13 accepted. Repositories that must be kept apart are not kept apart by profiles, in the bucket or in budget identity: the only bucket isolation today is not deploying a shared job. D4 and D13 are the owner's decisions in docs/plans/2026-10-14-project-centric.md, and the 2026-10-17 ruling is recorded in docs/design/bucket-iam.md §3 and §10.

  • A project job holds its Claude account's OAuth token, and every run of that job holds it too. Phase 3 deploys one Cloud Run job and one job service account per (profile, provider, Claude account) (gcp.ProjectJobName, internal/infra's ProjectJobs), and a profile's claude_accounts: list decides how many it has: every listed named account gets a job of its own whose container mounts that account's token (claude-oauth-token-<name>) as CLAUDE_CODE_OAUTH_TOKEN, and the profile's no-account job — the one job every profile keeps, whatever it serves (D9) — mounts the default account's unsuffixed claude-oauth-token exactly when the profile lists default (docs/design/project-named-oauth-tokens.md §4.3, D5). The mount is static and it is the platform's: the value is in the job's container, not in the run, so it is there from the start of every run of that job and no run can decline it. Each account job's service account holds secretAccessor on its own account's secret and on no other account's, and that is the whole of who can read a value: no launcher's own credentials read it, and the launcher's gate decides which job a run may be started on from the published policy and the run's own configuration, and never reads, seals or forwards a token (design D2; a read would need secretAccessor, which only the job and build accounts hold, see "Every launcher can write runs/" above). What a launcher can do instead is start a run on the job that holds the token, and a launcher is trusted: it writes the task text of every run it starts, so a launcher that may start a run on an account job can send a run that reads the token and hands it back — reaching the token that way is reaching everything the token can do (design D2; project-centric.md's D10, "launcher environment overrides: accepted and documented"). What bounds that is the account's list and not the launcher's credentials: only the repositories in accounts.<name>.allow_repos (published from claude_accounts.<name>.allow_data_to) are given that account's token, so the launchers of those repositories are the ones who reach it. What follows is this feature's own cost, stated by the design in §6.1:

    • A hostile run can use the token during its own run, and after it. The agent shares the container and its user with the runner (see "What one run's agent can reach" above), so a hostile run — or an agent talked into it by the repository, the task text or a comment — can read the token, copy it into an artifact, a pull request body, a commit or a log line (the redactor masks what Fugaro itself publishes, and is not a boundary), and use it until the owner revokes it. Nothing prices an OAuth run the way the gateway prices a provider-key run: there is no gateway in front of it and no per-call cap on it, only the token cap and the account's own usage limit ("Money" above).
    • The account is the blast radius, not the repository. claude_accounts.<name>.allow_data_to, published as the policy's accounts.<name>.allow_repos, decides which runs are given that token — the launcher's gate before any claim is taken, and the runner's own re-check before any stage, lease or budget spend, independently of the launcher — and neither of them scopes what the token can do. A repository the list admits gets a credential that speaks as that Claude account for every repository in the list; a repository the list does not admit is refused by both.
    • A profile that lists default gives that token to every run of its no-account job, whatever that run's agent.auth is. The token belongs to the job, so an api-key or Vertex run of that job holds the default account's token even though its own configuration selects no Claude account at all, and the account's list bounds it the same way (that the profile's list and the account's agree is the subset rule's business, projectpolicy.validProfileAccounts). That is documented rather than refused, deliberately: the same rule keeps an Anthropic API key off a profile that lists Claude accounts (design D4 — projectpolicy.validProfileAccounts, enforced when a policy is published, when fugaro setup builds the jobs and when a published node is read; fugaro validate is not one of the three — it reads the published node for the cascade's own switch and for nothing this rule covers), so such a profile's runs are OAuth or Vertex runs, or api-key runs under the gateway, where the Anthropic key is optional and a call that needs it is the gateway's to refuse rather than the launch's (agent.BuildEnv) — and the residual is a Vertex run, or one of those api-key runs, holding a token it does not use. fugaro doctor names the job, the account and the token for exactly this case, so an operator meets it without reading this file.
    • This carrier is a stopgap. It is the same static secret_env mount a per-repository OAuth run already uses, chosen because it needs no new mechanism (design D1), and it is replaced by the credentials/security service (docs/credentials-service/README.md, spec), after which no job mounts the token at all (design §4.6; the service as built is "The credentials service" above, and its operator side is the runbook). The swap changes the mount and nothing else: no job is renamed, and the policy block, the profile field and both gates stay as they are. It is not done in this release, and this bullet is the reason to say so here rather than only there: the switch takes a per-repository job's model mounts and accessors away (see "The credentials service" above), and the shared-job carrier above is not wired to it at all — projectAccountMount still adds the entry, and the phase-3 module has no switch input — so a shared project job keeps mounting the account's token with the service on. The service's refusal of a shared job's account is what keeps a cascade workflow off such a job in the meantime.
  • Follow-up runs act on pull request comments from the people in followup.trusted on the base branch (and, on GitHub, only owners, members and collaborators among them). A trusted person who quotes an untrusted comment passes it on. A follow-up's agent gets no git credential and no GH_TOKEN in its own environment — the runner fetches and pushes the branch itself, and the comments arrive in the prompt — but it still shares the container and user with the runner, so it can read the token from the runner's /proc, the same residual as the model and provider keys above.

  • The managed deployer exists in every installation, and its fence has not been measured. Every owner-run fugaro setup or fugaro update plans a fugaro-deployer service account (deploy/terraform/gcp/modules/installation/deployer.tf), belong included, and grants each principal in the installation's operators list roles/iam.serviceAccountTokenCreator on that account alone — so an operator, and nobody else, may impersonate it. It holds one unconditioned custom role on the project, fugaroDeployer, of create and read permissions (including iam.serviceAccounts.create, run.jobs.create, storage.buckets.create and serviceusage.services.enable), and no conditioned project role unless the installation uses managed projects, which is what sets the module's managed_deployer_scoped: the creating run writes it from the project's own creation fact for an installation that uses managed projects (internal/cli/init.go's installRoot), so such an installation gets the seven conditioned roles and their seven bindings, and an installation that never asks for managed gets the account and the unconditioned project role and no conditioned role at all. It also holds a ninth grant that is not one of those project roles and carries no condition: roles/storage.objectAdmin bound at the bucket level on the managed state bucket gs://fugaro-managed-tfstate-<project> (internal/managed/statebucket.go's EnsureManagedStateBucket, docs/design/managed-deployer-fence.md §3's F4a). That one is not gated on --managed or on managed_deployer_scoped: fugaro setup's D77 step writes it, on the operator's own credentials, for every project the creating run creates — belong included — and a fugaro setup that creates no project writes nothing. What confines it is the binding's scope, not a condition: it is bucket-level, on that one bucket, where a project-level roles/storage.objectAdmin would reach every bucket in the project. No condition can narrow it, because the state bucket deliberately does not carry the fmgd- prefix the seven conditioned roles admit — a condition that named the state bucket would put the state inside the fence around those seven. What the account is for is a feature that ships in 0.10.0, with its fence unmeasured: internal/cli's managedDeployerShipped is true as of 0.10.0, so the creating run with --create-project --managed, fugaro managed apply and fugaro restore are available, and a managed apply runs as this account (the operator impersonates it, on the project authority they already hold; it is the deployer that holds no project IAM authority beyond its own role and the seven conditioned ones). What is not settled is whether the fence works: no Check 68 run has shown that IAM accepts the seven conditioned bindings or honours resource.name per type, and that measurement is owner-run after the release. The fence, now that it ships, is seven conditioned roles — one per service, each bound once — whose conditions admit only names carrying the reserved fmgd- prefix, using resource.name.startsWith where the front of the name is fixed and resource.name.extract("<template>").startsWith where a middle segment is not knowable before apply — the extract templates are the form the design records — literal text around one placeholder, anchored at the collection segment the resource's own name follows ("/secrets/{secret}", "/jobs/{job}"), never the whole resource path — and the design holds that grammar UNVERIFIED: it is this repository's reading of the reference, and no source in the tree quotes the reference's text (design docs/design/managed-deployer-fence.md, §3's C6/C7 rows and option 1). One thing about it is unverified, and this document does not claim otherwise: whether IAM honours resource.name at all for an IAM service account, a Cloud Run job, a Cloud Scheduler job, an Artifact Registry repository or an API key — this is what Check 68 of docs/gcp-live-checklist.md, written, owner-run and not run, measures per service and in both directions. If it is not honoured for those five services, then the fence is Cloud Storage and Secret Manager and nothing else: a condition whose resource attribute cannot be evaluated is never satisfied, so those five roles grant nothing at all — fail-closed, and not a fence. And the Secret Manager half is measured, not expected: the name-prefix conditions this repository has measured live are Cloud Storage's (Check 32, RUN 2026-10-14) and Secret Manager's (Check 52's U3, RUN 2026-10-16: the condition is honoured — a read inside the prefix was allowed and one outside it denied). The other half — whether a name that does not carry its extract template's literal text fails closed rather than open — is UNVERIFIED, and this document does not claim otherwise: under the reading the design records, the call extracts the empty string, the empty string does not start with the prefix, and the condition denies. The tree models exactly that (internal/infra/tf's extractSegment) and pins it with the fail-closed rows of TestDeployerConditionFencesOnlyTheManagedPrefix — but extractSegment is this repository's reading written down rather than evidence for it, and no live step can construct the mismatch, so Check 68 cannot settle it either (design §3's C6/C7 rows, §9). Until Check 68 has run and passed, do not read the prefix condition as a boundary. A permission whose condition Google does not honour is dropped from the role rather than granted unconditioned, so an unsupported service costs the feature that type, not a hole. And what the fence does not do: it constrains the program's apply path, not an agent that goes around it — the agent's session holds the operator's credentials, which can impersonate the deployer (design §6 item 4).

The VM backend, and the Docker daemon it gives a run

Fugaro can run a workflow on Cloud Batch instead: one VM per run, for workflows that ask for docker: true. Nothing selects that backend in this release — an installation's config refuses backend: cloud-batch until it is enabled deliberately — and this is the model that decision is made with. The details are in docs/design/compute-backend.md §3.

  • A run that asks for Docker is root on its VM. The recipe mounts the VM's Docker socket into the container and adds the container's process to the socket's group (§2.2). Access to that socket is root-equivalence on the VM: docker run --privileged -v /:/host is the VM's filesystem, its service-account token and its network. It is not a new secret exposure inside the run — the agent already shares the container and its user with the runner and can read the runner's /proc (see "What one run's agent can reach" above) — but it is root on the host the run stands on, which the Cloud Run path does not have: there a job is a container with no daemon and no root.
  • That root is not the agent's alone — it is every command the repository gets to run. The socket is mounted for the whole run, so a docker call in the repository's own scripts, a test that starts a container, a Dockerfile's build steps, a Makefile target, a package's post-install step — anything the workflow or the repository causes to execute — reaches the same daemon with the same power as the agent's own commands. Fugaro makes no distinction between them: a run that asked for docker: true asked for the repository's code to have root on the VM, not only for the model to have it. Treat docker: true as a statement about the repository: enabling it for a repository whose code you do not review is enabling root-on-a-VM for that code, and the container boundary the "Out of scope" note below draws is the container, not the VM.
  • The VM is the isolation unit, and it is never reused. One run, one VM, deleted on every exit path. Nothing at the OS level crosses from one run's VM to another's: separate VM, separate kernel, separate Docker store. That is stronger than Cloud Run's shared job against other runs, and weaker than it against the host, which on Cloud Run does not exist.
  • The IAM boundary is unchanged. A VM runs as the job's own service account, at the same granularity project-centric D4 already accepted: by default the account of the (profile, account) pair, so every repository on that profile runs under one account, can read every object and secret that account reaches, and can poison another repository's cache and locks. A per-repository service account is expressible on this backend and would restore the per-repository boundary for the repositories that opt in; until an installation does that, the cross-repository loss is exactly what it is on Cloud Run's shared job.
  • The metadata server is reachable, by the agent and by every container it starts, so the VM's service account is one request away.
  • Root can outlive its own runner. Through the socket the agent can stop or kill the runner's process on the VM, and the VM keeps billing until the job's maxRunDuration or the sweeper's teardown ends it. It can also leave containers running that no Fugaro code knows about. That is why the VM, not the runner process, is the unit that gets reaped, and why a leaked VM is found by the sweeper and deleted.
  • The reaper is a scheduled sweep, and the account it runs as is a janitor's by construction. A leaked VM is ended by the sweep and not by the run, and the sweep is a process: fugaro reaper runs internal/backend/batch's reaper once, in a Cloud Run job the compute module creates for it, started by a Cloud Scheduler job every fifteen minutes — ReapMargin, so a job that should have ended is swept at most one margin after it should have. The Cloud Run job runs as the reaper account (a dedicated, project-wide account that shares nothing with the launcher's or the job's), and that account's project role holds exactly five permissions — batch.jobs.list, batch.jobs.get, batch.jobs.delete, compute.instances.list, compute.instances.delete — with nothing that can create a resource: no batch.jobs.create, no compute.instances.create, no setMetadata. It holds one grant besides that role, and on a single resource: roles/run.invoker on the reaper's own job and no other — run.jobs.run, which is what starting a job execution takes — because the Scheduler job mints its OAuth token from this same account. So the account can start the sweep it runs as and nothing else: it can still create no Batch job, no VM, and no execution of any image or command but this one. The role is a custom one because the predefined roles that name these verbs are far wider: roles/batch.jobsEditor carries batch.jobs.create — the power to run any container in the project, which is a launcher's power — and roles/compute.instanceAdmin.v1 carries compute.instances.create and the rest of the instance lifecycle, including the setMetadata that is how SSH keys reach a VM. So a mistake in this component ends resources; it never starts them. Its reach is wider than its name suggests, and the reason is IAM's: no condition can say "only jobs and VMs labelled fugaro", so the five permissions are project-wide and the account can delete any Batch job and any VM in the project. What makes that acceptable is on the code's side, below.
  • What the reaper refuses is what makes its reach acceptable, and the refusals are the review's subject. It deletes an instance only when it carries both this installation's fugaro=managed marker and a fugaro_run label, and only when no job the pass did not clear carries that run — that is, the run's job reached a finished state, or no job in the listing mentions the run at all — and the instance is in the reaper's own region and past the age floor. "Not cleared" is wider than "not running", deliberately: a job the pass refused to act on (an unknown state, an unreadable creation time, two label fields that disagree about the run) may still be running, so its VMs are not this pass's either, and a refusal never hands the VM it just declined to explain to the instance pass; a job this pass or the queue deadline deleted is not cleared for that pass either, because Batch tears a deleted job's VMs down itself and deleting them in the same pass would race that. Only a finished job, and a run no job in the listing mentions, frees a run's VM. It deletes a job only when the job has sat in QUEUED past the queue deadline (and the reaper reads such a job back before deleting it, so a run that started in between is never the one erased), when it is stuck past every deadline it could legitimately have had, or when it is stuck in DELETION_IN_PROGRESS. Everything else is refused and named in the report: a job or VM whose time it cannot read, a job whose state this build does not know, a job whose two label fields disagree, an instance young enough that Batch may simply be mid-teardown, a VM whose run label it cannot match, and anything outside the project and region the job was pointed at. A reviewer answering "what does it delete that it should not?" from this paragraph alone should compute the queue-deadline slack: the deadline is the installation's longest task timeout or a run's own ceiling, whichever is longer, plus the launcher's slack, and that margin is the whole of the defence against deleting the VM of a run that was still starting. The report — one line per action and per refusal, naming the target and the reason — is what the Cloud Run job prints, so it reaches Cloud Logging under the job's own fugaro_role=reaper labels; a pass that errored prints the part it finished and exits non-zero, so a partial sweep is never read as a clean project.
  • The budget caps stay model-only, and bypassable. The gateway's caps count model dollars and are enforced in-process by fugaro exec. An agent that holds the provider key — on this backend, every container it starts can read it from the host — can call the provider directly, off the gateway, and nothing halts it. The VM's compute also keeps billing after the runner is gone. The backstop is the provider account's own credit limit, the same one "Money" above names.
  • Who can launch, and what that reaches. On Batch a launcher needs roles/batch.jobsEditor on the project, plus roles/iam.serviceAccountUser on the account a job names. A project-level batch.jobsEditor is not confined to Fugaro's repositories, images or commands: a launcher can create a job with any image, any command and any secret it can name, running as any account it holds serviceAccountUser on, and can cancel or delete any job in the project, including another launcher's. The Cloud Run path is narrower — a launcher runs one named job per (repository, workflow), so the image, the command and the environment are the job's. Enabling this backend trusts launchers further.
  • Egress is unchanged. A run's VM has no external IP, but egress is still unrestricted: the agent can send whatever it can read anywhere the network allows, exactly as the Cloud Run path does ("Network egress is not restricted" above).

Running Fugaro on repositories with external contributors

  • Do not launch runs whose task text you copied from an untrusted issue or comment without reading it: the task is an instruction the agent follows.
  • Keep followup.trusted to people with write access.
  • A first run or follow-up gives a fork's pull request no special handling at all. If such a run checks out and executes a fork's code — a build script, a test command — that code runs with the workflow's secrets, the same as any other code a run touches. Treat a fork's pull request with the same caution as an untrusted task.
  • mode: review (review-only, pick) is trusted-author-only (owner ruling 2026-10-13): it reviews a pull request only when the author's LIVE GitHub author_association is OWNER, MEMBER or COLLABORATOR, right now, or the author is Fugaro's own identity on a pull request it opened itself — fork or not. followup.trusted, the account-ID allow-list a follow-up's own comment trust also consults, plays NO part in this decision: unlike the live association, a list entry does not expire or re-check itself, so a person removed as a collaborator but left on the list would otherwise stay trusted here indefinitely. An outside contributor's pull request is refused outright, whether or not its head is in a fork; there is no flag to let one through. A trusted author's own fork is reviewed only when the fork repository's owner is that same trusted author, so a shared or organizational fork can't carry someone else's commits under a name the trust check never sees. Bitbucket has no author_association at all: review mode accepts only a same-repository branch there — on the premise that pushing one already needs write access — and refuses every fork. A workspace or project token breaks that premise (see "Narrow git credentials" above): an agent of an allow-listed repository can push a branch and open a pull request in another allow-listed repository. A hostile agent in one repository can open its own malicious pull request in another, and a later review-only run there trusts it. That is accepted rather than mitigated (D476), and the docs tell operators never to put the token's own account ID in followup.trusted. None of this is a sandbox, though: the reviewing agent is still the same unsandboxed process, as the same user, in the same container, as every other run. A trusted author's own prompt injection — the diff itself, a commit message, and, in a first run or follow-up, the checkout's own CLAUDE.md and .claude/rules, which those runs load as instructions (review mode removes both from the checkout and gives the reviewer the base branch's own instead, so there they reach it as the diff content they are, not as loaded instructions) — is still code execution under --dangerously-skip-permissions, and that code can still read the runner's own /proc environment (see "What one run's agent can reach" above) for every workflow secret, the GitHub App's private key, the real provider keys and the job's service account token, and send any of it out over the open network egress nothing here restricts — redaction of the posted comment and the logs only ever masks what Fugaro itself publishes, never a secret a running process exfiltrates on its own. Trusting a person this way also trusts what THEY trust: a collaborator who lets other people push to their own fork, or who enables "allow edits by maintainers" on their pull request, or who merges someone else's commits into their own branch, extends review mode's trust to all of it — the check is "whose fork, and whose association", not "whose commit". "Member" and "collaborator" are GitHub's own categories and can be broader than you expect: a MEMBER is any member of the organization, even one with no access to this particular repository, and a COLLABORATOR can be a read-only one. A stricter check (the collaborator's actual permission level) is possible future hardening whose required GitHub App permission is unverified; see docs/recipes.md §3 for the full account.
  • Give Fugaro its own provider keys with credit limits, and its own repository access — a GitHub App on GitHub, a repository access token on Bitbucket, or one workspace or project access token allow-listed for many — installed only on the repositories it serves, and only where the token's reach is what you mean (see "Narrow git credentials" above).
  • Public repositories are refused for follow-ups unless followup.allow_public is set, for the same reason.

Scope

In scope:

  • The budget gateway, the Realtime Database rules and token handling that enforce the shared budget
  • Credential handling: secrets in Secret Manager, fugaro secrets, redaction of logs and PRs, the runner's use of provider and model tokens
  • The IAM and Terraform that fugaro setup creates (per-repository isolation, custom roles, bucket conditions)
  • The CLI and the runner (for example path or command injection from a task, a repository's fugaro.yaml or a PR comment)
  • The release pipeline (checksums, signatures, the Homebrew cask)

Out of scope:

  • An agent doing harmful things inside its own container with the credentials that job's service account is allowed to hold: the trust boundary is the container, and the design says so openly
  • Problems that need an attacker who already holds your cloud project's owner credentials
  • Vulnerabilities in Google Cloud, GitHub, Bitbucket or a model provider themselves; report those to the vendor
  • Denial of service by spending your own budget

The trust boundary and what each credential can reach are described in docs/design/v1.md (section 6, Security model).

There aren't any published security advisories