Report vulnerabilities privately to the maintainers through GitHub's private vulnerability reporting on this repository. Do not put credentials, private repository content, model prompts, or workflow logs from a private repository in a public issue.
Each installation is self-hosted. It consists of one Cloudflare Worker with a D1 database and Workers AI in the operator's Cloudflare account, plus generated workflows in each enrolled repository. The installation is the tenant boundary. Gardener runs no shared service and receives no data from installations.
The Worker holds no GitHub credential. Repository access comes only from the GITHUB_TOKEN that
GitHub issues to each workflow job, with permissions fixed by the generated workflow.
| Component | Trusted with | Never receives |
|---|---|---|
| Worker | Task bundles, run state, plan validation, receipts | Any GitHub token |
| Plan job | Read-only GITHUB_TOKEN, OIDC token, checkout |
Write permissions |
Model and repository.exec |
Repository contents, via declared tools only | Any GitHub token, the OIDC token |
| Apply job | Write scopes implied by the task's declared effects | A checkout, repository code, model output other than the validated plan |
Untrusted inputs are:
- model output;
- repository contents;
- issue, pull request, and comment bodies;
- anything a
repository.execprocess writes.
Each one can influence what is proposed. None of them can widen what is allowed.
The Worker exposes three routes: GET /health, /session/<id> and POST /v1/sync. Every other
path returns 404. /v1/sync accepts a repository's compiled tasks only with a GitHub OIDC token
from the pinned sync workflow, run on the repository's default branch.
Opening a session grants nothing until the runner presents a GitHub OIDC token. The Worker verifies
the token signature against GitHub's issuer and requires all of the following:
- the enrolled numeric repository and owner IDs;
- the run ID and attempt, the event name, the ref, and the commit SHA;
- a GitHub-hosted runner,
runner_environment: github-hosted; - the exact reusable workflow reference,
job_workflow_ref, pinned to a full commit SHA; - the Worker's own origin as audience;
- the job phase, plan or effects.
Tokens bound to a GitHub environment are rejected. A disabled repository cannot authenticate either phase.
- The plan job checks out with
persist-credentials: false. Its token is read-only. - Task definitions are canonical
TaskBundleV1JSON stored by SHA-256. The Worker loads only hashes enrolled for the authenticated repository, recomputes the hash, and checks the event against the task's declared triggers. It never parses Markdown or YAML. - The model reaches the repository only through the task's declared tools.
provider.api.readallows RESTGET/HEADand GraphQL queries. The bridge process holds the token, so it never reaches model context or a child process.repository.execruns with the token and OIDC request variables removed from its environment.
- The model proposes effects one at a time. It supplies a step name, an operation kind, a payload,
and references to earlier steps' outputs. The Worker validates each proposal against three
constraints:
- the task's declared effects;
- the strict operation schema;
- typed references, which may point only to scalar outputs of earlier steps.
- The Worker derives the parts the model must not control:
- operation IDs;
- repository identity;
- idempotency markers;
- commit file contents.
- Fork pull requests are rejected twice: in the generated workflow, and by a runtime head-repository
check.
pull_request_targetcannot be generated.
Before the model runs, the Worker decides whether the run's starter is trusted, from the signed
OIDC event name and actor and the event Gardener's bridge reported, and freezes the answer in the
run's request. Trusted starters are schedules, manual runs, pushes and same-repository
synchronize by a person, a subject's author who counts as a maintainer, and anyone else with
write or admin access as the bridge looked it up with the read-only token. Bots, outsiders, fork
heads and failed lookups are untrusted; every doubt narrows. See
Effect scope.
An untrusted starter's run is confined to its trigger. What that guarantees:
- every step that targets an issue, pull request or discussion targets the one that triggered the run, matched by kind and number, or one an earlier step of the same plan created, except that a merge or review must name the triggering pull request itself, so a run can't merge or approve a pull request it opened;
- no step edits, publishes or deletes an existing release, or re-runs a check;
- the same pure check runs when each step is proposed, when the Worker builds the plan, and when apply re-parses the digest-bound plan, so the three cannot disagree. Plans for runs pinned to 0.1.16 or earlier don't carry the scope, because their apply would refuse the field; those keep the first two checks.
Each task's run limits are enforced in one atomic D1 statement before the model starts: by default an untrusted starter may cause three runs of a task a day.
A task that commits code does not put file bytes in its plan. When a commit is proposed, the bridge captures the working tree changes into a separate artifact. The manifest records each path's status, mode, size, and SHA-256, and the plan binds the manifest digest.
A capture may not write these paths:
- anything under
.git/,.github/workflows/,.github/actions/, or.gardener/; CODEOWNERS,.github/CODEOWNERS, anddocs/CODEOWNERS;.github/dependabot.ymland.github/dependabot.yaml.
Apply verifies the artifact against the plan, then re-verifies each file's size and digest as it uploads the file.
The apply job has no checkout and runs no repository code. It performs these steps:
- Download the plan artifact and verify its SHA-256.
- Authenticate as the effects phase.
- Have the Worker confirm the plan is the one it issued for this run.
- Execute each operation in order.
- Send the plan's message, if it has one, once every operation applied.
- Start the plan's task run, if it has one, after that, whatever became of the message.
Every operation carries the GitHub state it was planned against. Depending on the operation, that can include:
- the issue or pull request state and
updated_at; - the head SHA;
- the comment timestamps;
- the release state.
Apply re-reads that state before writing. A mismatch is a terminal conflict, never a retry.
A plan can write the same issue, pull request, or discussion more than once, for example a comment
followed by a label. After a write that passed its updated_at check, apply reads back the new
updated_at and records it in the step receipt. A later step on that resource accepts either its
planned value or that recorded value. Changes by anyone else still conflict, except for one landing
in the moment between Gardener's write and its read-back. A step that found its effect already in
place never extends the chain. Two writes to the same release or comment in one plan still conflict.
Apply stops at the first failure. Re-running the apply job resumes after the last operation that succeeded exactly. Operations can be matched after an interrupted run through markers that trusted code adds:
- an HTML comment,
<!-- gardener-operation:<id> -->, on comments, reviews, and pull request bodies; - a
Gardener-Operation:trailer on commits.
Gardener branches, commits and pull request heads are limited to gardener/** branches unless a
task lists other branches on that effect. A wildcard never matches the default branch: a task can
write to it directly only if it names it exactly. Every commit sits directly on the commit the task
checked out. Branch lists do not limit two pull request kinds. pull_request.merge can merge a pull
request into any base, including the default branch, so its required checks are the gate.
pull_request.update_branch can update any pull request's head with its base; with
method: rebase GitHub rebases and force-updates that branch, which changes its commits and can
dismiss reviews. GitHub computes that history and applies it only if the head is still the one the
task saw. No other operation can force-push, and none can delete a branch.
A plan carries at most one message: a subject (an issue, pull request or discussion, by number and
the updated_at the model read) and a summary. The destination is not in the plan, the receipt or
anything the model sees. It is the GARDENER_CHAT_WEBHOOK secret, which the generated caller
passes only for tasks that declare message.send, and which Gardener's reusable workflow
interpolates only into the apply step. The planning job, which runs the model, never receives it.
The apply step masks it, deletes it from its environment, and posts only to
https://chat.googleapis.com/ or https://hooks.slack.com/, by exact host, with redirects refused.
The only thing apply adds to the URL is Google Chat's messageId, a hash of the plan artifact's
digest, which the receipt records anyway, so it reveals nothing. Errors from the chat service are
cut short and scrubbed of the URL and its parts before they reach the log or job summary. A task may not declare both message.send and repository.exec, so no
task-controlled command runs in a job that could see the secret.
The summary is limited when the model proposes it: one line, at most 400 characters, no <, >,
&, backticks, control or format characters, and no Gardener marker. Apply then fetches the
subject's title, author and link itself, and refuses to send if the subject changed since planning,
except by the plan's own steps, or if its link leaves the repository. The title and summary are
sanitized with the same rules, and so is the repository name: NFKC normalization first (so
fullwidth < or . count as < and .), control and format characters dropped, one line,
lookalikes for every formatting character either product knows (<, >, *, _, ~,
backticks), and link-like text defanged (:// becomes :∕∕, and the dots of anything shaped like
a host name, in any script and including 。, become U+2024), with Slack's & escaped first. A
leading list marker on the summary line is swapped for a lookalike. Bare IPv4 addresses and
localhost are not defanged; neither product links them without a scheme. No code span is relied on, so the only link in a
message is the one Gardener built to the subject. #123 and @login stay as text; neither product
turns them into a link or a mention in incoming-webhook text.
A plan carries at most one task run: a target task id, the workflow path the Worker derived from it
(.github/workflows/gardener-<id>.yml, never from the model), and a subject that is the run's own
issue or pull request, or a pull request an earlier step of the same plan opens. The model supplies
no inputs, prompt, ref or workflow. The Worker checks the target is declared, enrolled and enabled,
and offers the subject as a dispatch input, both when the step is proposed and when apply reports
dispatching; apply re-checks the path pattern and dispatches on the default branch it reads from
the API, with exactly one input. It never dispatches twice.
Lineage. A started run gains nothing from being started. Before apply calls GitHub, the Worker
records the dispatch (target task, subject, the parent's frozen starter decision, root author and
depth). A manual run whose actor is a bot is never trusted as a manual run: it must claim one of
those records (same repository, task and subject, unconsumed, at most six hours old) in one atomic
compare-and-swap, and inherits that record's decision, never a wider one, with the strictest record
handed out first when several match. It is charged to the chain's root author, may not carry a
prompt, and stops at depth 3. A re-run of the same workflow run finds the record it already
claimed. A bot dispatch that claims nothing fails before the model, with an actions_task_audit
row. Every matched run writes an audit row naming its parent run and depth. A person's manual run
is unchanged: it still needs write access to start.
actions: write. A task that declares task.run needs actions: write to dispatch. GitHub
does not evaluate expressions in permissions:, and apply deliberately inherits the caller job's
grant (its needs vary by task), so the grant cannot be scoped to the dispatch:
- only tasks that declare
task.runget it, and it is granted on the caller job, so apply,acknowledgeandsettleall hold it for the duration of the run; - beyond dispatching, it permits cancelling, re-running and deleting workflow runs, logs and artifacts, enabling and disabling workflows, and managing caches;
- it is contained: the plan job pins a narrower static block, so the model and any
repository.execcommand never hold it; apply is checkout-free and runs one pinned bridge against a hash-bound plan;acknowledgeandsettlerun fixed API calls; - the residual: the Actions run's own logs and artifacts are deletable by the same token that
applies effects, so for a repository that uses
task.runthe durable audit record is the Worker'sactions_task_audittable, not the Actions logs.
The option not taken: a second reusable workflow, called by a second generated caller job with a
static permissions: { actions: write }, taking the resolved task and subject from the first job's
outputs. It achieves least privilege at the cost of a second pin, a second job_workflow_ref for
the Worker to accept, outputs plumbing, and a dispatch outside the receipt flow.
- No human approval. Declared effects apply automatically. A task that declares merge, release, or check-rerun authority can use it unattended.
- Unrestricted egress from
repository.exec. It runs as the runner user with the runner's normal network access, so it can exfiltrate anything in the checkout. A hostile process can also tamper with the plan job's step outputs. It cannot change what is applied: the Worker independently re-derives the plan and its digest before apply, and any mismatch fails closed. - The default branch decides what runs. On each push that touches Gardener's files, the
generated sync workflow enrolls exactly the committed tasks. The runtime accepts a sync only from
Gardener's pinned sync workflow, verified by the GitHub OIDC
job_workflow_refclaim, running on the default branch (ref) of a connected, enabled repository. So pull requests, other branches and forks can't change enrolled tasks. But anyone who can push to the default branch can grant a task any effect. Protect the branch (required reviews, or CODEOWNERS for.gardener/**) where that matters. An unprotected branch is supported. The runtime trusts whichever branch GitHub reports as the default at sync time, so a repository admin who renames the default branch to an unprotected one can sync from it. Protecting the branch also means limiting who administers the repository. - Sync workflows come from Gardener's own repository. The runtime accepts a sync only from
the repository that ships the release it was deployed from (its
GARDENER_RELEASE_WORKFLOW_REF), at the repository's enrolled commit or that release's commit.connectrefuses a lock pinned to any other repository. - Trigger scope confines where, not what. A run that outsiders can start still reads their
text, and a task that declares
pull_request.mergecan merge the outsider's own pull request if that text persuades the model.generatewarns about such tasks. - Maintainer-started runs read untrusted text. A maintainer's comment on an outsider's issue
gets repository scope, and the model still reads the outsider's issue. Keep instructions and
effects narrow for tasks maintainers start on others' threads, or set
effect-scope: trigger. - Bot pushes are untrusted. A push or
synchronizeby a bot, including Dependabot, is confined, and with nothing behind it can only create: it may open issues, pull requests and branches. branch.createunder wide patterns. A confined run may create any branch its task'sbranchespatterns allow. The run limits bound how many.- Many accounts.
runs-per-authorcounts each GitHub account separately. Only a task'srunslimit, or the AI Gateway cap, bounds someone with many accounts, andrunshas no default. - Messages show outsiders' text. A message quotes the subject's title, which its author wrote, and a summary the model wrote after reading untrusted text. Both are inert, but they can still say misleading things. In a private repository, everyone in the chat space sees the title.
- Slack messages go out at least once. Slack incoming webhooks take no idempotency key, so a
rerun after a lost answer can post twice. Google Chat posts carry a
messageIdderived from the plan's digest, and Chat refuses a repeat, so a plan's message reaches a Chat space at most once. - Kill switch.
gardener repository disableblocks planning, apply and syncs for the repository. To stop one task, remove it from the default branch.
.gardener/ and the generated workflows contain no secrets. The CLI keeps no local installation
state: it reads the installation from the Cloudflare account, and deploy records the runtime URL,
CLI version and deployment digest (none of them secret) in the workspace's D1.
A CLOUDFLARE_API_TOKEN, when provided for Cloudflare Access, is used in memory only. It is never
written to disk, D1, the Worker, GitHub, or command arguments.
A GARDENER_AI_GATEWAY_TOKEN, when provided for an external AI Gateway,
is piped to wrangler deploy and stored only as the runtime Worker's secret. It is never written to
disk, D1, GitHub, or command arguments, and never reaches a runner. Anyone who can edit the
runtime Worker can use it. The gateway sees every request, including repository content the model
reads.
A GARDENER_CHAT_WEBHOOK repository secret, when set, is a credential for posting to one chat
space. Anyone with it can post there; anyone who can edit repository secrets can redirect
messages. It is never written to the plan, receipt, D1 or job summary.
If you suspect compromise, disable the repository with gardener repository disable. Preserve the
D1 database. The database blocks updates and deletes on the run audit table, actions_task_audit, so
its rows serve as evidence.