diff --git a/README.md b/README.md index e87bacb5..dc218cf2 100644 --- a/README.md +++ b/README.md @@ -1229,8 +1229,9 @@ pull request reads it through `context`, since the runner holds no GitHub token; a new plan reads your idea and pasted context there. kix publishes as its GitHub App, so the runner never pushes. A job that hits an account's limit pauses for you to pick another account; the runner never switches on its own. On SIGINT or SIGTERM, running work is published and its jobs move to another -runner. Runners take only trusted revisions until they can build untrusted -heads' devShells in a VM. A pull request's head is trusted only when every +runner. `quest runner` takes only trusted revisions until it can build +untrusted heads' devShells in a VM: it registers without declaring VM builds, +so kix routes it no other job, and it fails one if sent. A pull request's head is trusted only when every commit beyond the default branch has a valid signature from a current maintainer, so sign your commits (for example with [SSH signing](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#ssh-commit-signature-verification)) diff --git a/SETUP.md b/SETUP.md index 65591217..cdab05f7 100644 --- a/SETUP.md +++ b/SETUP.md @@ -82,6 +82,17 @@ job the condition `github.event_name != 'pull_request' || github.event.pull_request.draft == false`. Change workflows only if the user agrees. +Agents push their quest branches to the repository itself, and GitHub gives +workflows on those branches the repository's secrets and default token, so +recommend keeping that CI read-only: code no maintainer has reviewed should +meet only a read-only `GITHUB_TOKEN` and no write credentials. In GitHub, set +Settings → Actions → General → Workflow permissions to read; move repository +secrets, and organization secrets shared with the repository, into +environments whose deployment branches are limited to protected branches; and +keep write `permissions:` out of jobs that pull requests and pushes to other +branches run. kix checks the same settings when a repository enrolls. +Change settings and workflows only if the user agrees. + Run `quest check`, then suggest next steps: restart the agent session so it loads the new skills, then `/quest-plan` (`$quest-plan` in Codex) to plan work or `/quest-import` to bring in open GitHub issues. diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md index 94475cf6..f0a8ef27 100644 --- a/cloudflare/GITHUB.md +++ b/cloudflare/GITHUB.md @@ -16,6 +16,10 @@ and installation tokens stay inside the Worker; agents receive neither. | Pull requests: write | Observe, open and update job PRs, explicitly merge an exact head | Read or write for that operation | | Checks: write | Observe checks; record a runner's review as the `quest review` check run | Read for check reads; write only to record a runner's review | | Commit statuses: read | Observe legacy CI status contexts | Check reads only | +| Administration: read | Read whether the default workflow token can write | The setup page's [CI check](#read-only-pull-request-ci) only | +| Secrets: read | List the names of Actions secrets that reach every workflow | The setup page's CI check only | +| Actions: read | List environments and their deployment branch policies | The setup page's CI check only | +| Environments: read | Count each environment's secrets | The setup page's CI check only | The manifest subscribes to push, issues, issue comments, pull requests, reviews, review threads, inline review comments, checks and statuses. Existing App registrations @@ -26,12 +30,32 @@ user permission, which Better Auth needs to sign in users whose email is private It grants no repository authority: the user token is used only for the sign-in profile and email reads and to list the [project picker](#project-enrollment)'s repositories, and never for repository writes or authority checks. No organization, -administration, or workflow permissions are requested. These installation grants +administration-write, or workflow permissions are requested, and GitHub never +returns a secret's value, only its name. These installation grants are necessarily broader than individual operations: minted tokens always name one repository ID and the particular permissions required. Tokens are never cached and are revoked after use; failed cleanup is logged without secrets and does not retry a successful write. GitHub also expires tokens after an hour. +## Trust by stage + +Each stage of a job trusts as little as it can. Untrusted text, such as an +issue, a pull request, a comment, a prompt answer, or anything an agent writes, +is data at every stage: it never grants authority, approves work, or changes +what kix may write. + +| Stage | What is trusted | Why | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Intake | GitHub's live answers to kix's own reads. A webhook only asks for a read; its signature proves GitHub sent it, not that its contents are current | Deliveries arrive late, out of order, or not at all, and bodies are anyone's text ([webhooks](#webhooks-and-revocation), [project state](#project-state-and-activity)) | +| Job creation | The signed-in maintainer's request, after a live check of their maintain or admin role, bound to the revision or head the maintainer saw | A browser's claims grant nothing, and a head that moved since observations read it is refused ([README](README.md)) | +| Routing | The requester's own runners, and their owner's word for each runner. Untrusted heads go only to Linux runners whose owner declared VM builds; kix does not verify the flag | A runner is its owner's machine and risk, and jobs never reach another user's runner | +| devShell | A trusted revision: the default branch, or a pull request head whose every commit beyond the default branch carries a valid signature from a current maintainer, or whose exact SHA a current maintainer approved in the inbox (`head_approvals`). A head kix pushed to is trusted only by such an approval | The runner's host Nix evaluates the flake outside the sandbox, and "kix published it" proves only that a runner sent it. An approval lapses when the head moves or its approver loses the role ([README](README.md)) | +| Agent run | Nothing the agent does. Its commands run in an OS sandbox with no GitHub credential, and it acts on an answer only once the runner's owner gave or approved it | Issue and pull request text can inject prompts ([README](../README.md)) | +| Publication | Nothing in the submitted files. kix writes them as Git data to the job's branch or the same-repository head it iterates, refuses workflow files and the default branch, and fences out stale attempts | A prompt-injected agent or a compromised runner can send anything ([Publication](#publication)) | +| CI | The enrolled repository's settings keep pull-request CI read-only. A runner's request to mark its pull request ready is its owner's statement, and under read-only CI it starts only harmless checks | GitHub gives same-repository branches' workflows the repository's secrets and default token, unlike fork pull requests ([Read-only pull-request CI](#read-only-pull-request-ci)) | +| Review | A review that counts on the exact head: a writer's approval, the App's `quest review` check, or the author's own `## Review` record on a commit no App made. An agent's recommendation never counts | Picking merge approves the head the maintainer saw, not whatever is pushed after it ([Landing only reviewed heads](#landing-only-reviewed-heads)) | +| Land | The maintainer's explicit pick of that head, under GitHub's branch rules and checks | kix never writes the default branch itself; GitHub's refusals stand ([Merge jobs](#merge-jobs)) | + ## Trusted boundary `src/github/service.ts` provides specific server-only operations for quest @@ -241,8 +265,9 @@ An author's record carries only across merges no App committed. kix defers to GitHub only where GitHub enforces the same rule: a ruleset on the base that requires an approval and either dismisses stale approvals or requires approval of the most recent push, which GitHub reports this App cannot bypass. -Classic branch protection's review settings need the administration permission -to read, so kix keeps its own check there. +Classic branch protection's review settings need the administration +permission, which landing tokens do not carry, so kix keeps its own check +there. When no review counts, a direct merge is refused and a merge job pauses on a decision. Both say which commit needs a review and how to give one. @@ -281,15 +306,47 @@ one, kix turns that pull request's auto-merge off. If GitHub refuses or does not show whether it is on, the job pushes nothing and pauses on a decision. The new head lands only through a new landing. +### Read-only pull-request CI + kix pull requests come from upstream branches, and GitHub gives workflows on -same-repository branches the repository's secrets, unlike fork pull requests. -So enrolled repositories must keep pull-request CI read-only: a read-only -default `GITHUB_TOKEN`, no write secrets at repository level, and write -credentials only in environments limited to protected branches. Agent-written -code then runs in CI before review with nothing it can write through. -`pull_request_target` and `workflow_run` jobs that check out a PR head fall -under the same rule. kix never runs pull request code in the publisher and -grants runners no GitHub credentials. +same-repository branches the repository's secrets and default token, unlike +fork pull requests. Pushing a job branch also runs its `push` workflows. So +enrolled repositories must keep that CI read-only: a read-only default +`GITHUB_TOKEN`, no secrets at repository or organization level, and secrets +only in environments limited to protected branches. Agent-written code then +runs in CI before review with nothing it can write through. Jobs whose +workflow asks for write `permissions:` itself, and `pull_request_target` and +`workflow_run` jobs that check out a PR head, fall under the same rule. kix +never runs pull request code in the publisher and grants runners no GitHub +credentials. This rule, rather than a service fork or a review gate on every +push, is kix's answer to agent-written code reaching CI. + +The project's setup page, where a maintainer enables contributions and later +pauses them, or opens the [setup pull request](#setup-pull-request) whose merge +enables them, checks the repository's settings with the installation's token +(`src/github/ci.ts`) and lists each problem it finds: + +- the default workflow token can write; +- Actions secrets at repository level, or organization secrets shared with the + repository, by name; +- an environment with secrets whose deployment branches include one GitHub + does not report protected. + +An environment limited to protected branches passes while some branch is +protected, since GitHub lets every branch deploy when none is. One limited to +selected branches passes when each branch pattern names a protected branch, +or the merge queue's `gh-readonly-queue//*` for one; a wildcard could +match a branch kix writes, so it is reported. Tag patterns pass, since kix +never writes tags. Required reviewers on an environment do not count: a +reviewer approves a deployment, not the code a job branch carries. + +The check only warns: enabling still works, and jobs still run. A failed read +becomes a warning of its own. It makes at most 30 reads, so the page stays +within a Worker's subrequest budget, and says when it stopped before checking +every environment. It +does not read workflow files, so the page reminds the maintainer of the rest +of the rule. An installation that has not accepted the check's permissions +gets a warning naming them instead of the checks they cover. Installation grants are broader than any one publication: `contents: write` and `pull_requests: write` cover every installed repository. Each token names @@ -387,11 +444,16 @@ reconcile current provider state rather than replay issue/PR payloads as facts. recorded and their heads need a person's approval to land. GitHub's installation UI, not browser form claims, chooses repository access. Enroll each repository on `/projects`; jobs publish their branches there, with no fork setup. -4. Run `bun run wrangler d1 migrations apply DB --remote`, then the normal +4. Existing App registrations must add the **Administration**, **Secrets**, + **Actions**, and **Environments** repository permissions, each read-only, + which the setup page's [CI check](#read-only-pull-request-ci) reads with. + Each installation then accepts them on GitHub; until it does, the check + names them instead of running. +5. Run `bun run wrangler d1 migrations apply DB --remote`, then the normal Worker deployment from [README](README.md). GitHub App setup, credentials, production deployment and live installation tests are operator work; this change performs none of those external actions. -5. Send a GitHub ping, check successful delivery, then test installation +6. Send a GitHub ping, check successful delivery, then test installation suspension/removal and repository deselection in a development installation. Check that service operations reject revoked access and reduced permissions. Use GitHub's delivery/redelivery UI to retry failed deliveries. Rotate the diff --git a/cloudflare/README.md b/cloudflare/README.md index 8cbcbde6..656d6892 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -104,7 +104,14 @@ on screen records the approver, the SHA, and the time in `head_approvals`, one per pull request. An approval lapses when the head moves and counts only while its approver keeps a maintain or admin role. The check runs at creation and again at assignment. Other heads, including unsigned ones and -those kix pushed to, route only to Linux runners that declare VM builds. A `merge` job runs the merge +those kix pushed to, route only to Linux runners whose owner declared VM +builds when registering them, a statement kix does not verify +([trust by stage](GITHUB.md#trust-by-stage)). `quest runner` cannot build in a +VM yet: `quest runner login` registers it without declaring VM builds, so such +jobs wait for a runner that declares them. kix trusts that declaration, so a +runner declared through `POST /api/runners` with `vm: true` that cannot build +in a VM is still sent the job; `quest runner` fails it rather than run it. A +`merge` job runs the merge skill's loop on an open, non-draft pull request and lands it; it holds the PR with its own `quest:kix` label, which local sessions respect as they do each other's `quest:active` (`src/jobs/landing.ts`), as described in @@ -119,7 +126,8 @@ on the machine and prints a link to `/runners?name=…&platform=…&key=…`, wh `key` is the token's SHA-256; the signed-in user approves it there (`POST /runners/connect`), and the runner polls `GET /api/runner` with its token until it is accepted. A script can instead register at `POST /api/runners` and -get a token back. Either way kix stores only the hash. With that token a +get a token back, declaring `vm: true` if it builds untrusted heads in a VM. +Either way kix stores only the hash. With that token a runner opens a WebSocket at `/api/runner/connect`. `/runners`, linked from the decision inbox, lists the user's runners with their presence and accounts, and moves an account earlier or later in the preference order diff --git a/cloudflare/dev/github.ts b/cloudflare/dev/github.ts index 8fa419c9..2850f5d6 100644 --- a/cloudflare/dev/github.ts +++ b/cloudflare/dev/github.ts @@ -607,7 +607,17 @@ function workflowsOf(repo: Repo, sha: string): Record { return files; } -type Permission = "metadata" | "contents" | "issues" | "pull_requests" | "checks" | "statuses" | "administration"; +type Permission = + | "metadata" + | "contents" + | "issues" + | "pull_requests" + | "checks" + | "statuses" + | "administration" + | "secrets" + | "actions" + | "environments"; /** What an installation token may do: the repositories (`owner/name`) and permissions it was minted for. */ type Grant = { repositories: Set; permissions: Partial> }; @@ -775,9 +785,12 @@ export function fakeGitHub(world: World) { if (match && tail === "/installation") { if (!app) return unauthorized(); if (!repo) return notFound(); - const permissions = Object.fromEntries( - ["metadata", "contents", "issues", "pull_requests", "checks", "statuses"].map((p) => [p, "write"]), - ); + const permissions = { + ...Object.fromEntries( + ["metadata", "contents", "issues", "pull_requests", "checks", "statuses"].map((p) => [p, "write"]), + ), + ...Object.fromEntries(["administration", "secrets", "actions", "environments"].map((p) => [p, "read"])), + }; return json({ id: world.installation, app_id: world.app.id, suspended_at: null, permissions }); } if (!scope) return unauthorized(); @@ -957,6 +970,16 @@ export function fakeGitHub(world: World) { if (!scope.permissions.administration) return forbidden(); return repo.protection ? json(repo.protection) : json({ message: "Branch not protected" }, 404); } + // The setup page's CI check finds read-only pull-request CI: a read-only + // default token, no secrets, and no environments. + if (tail === "/actions/permissions/workflow" && method === "GET") + return scope.permissions.administration + ? json({ default_workflow_permissions: "read", can_approve_pull_request_reviews: false }) + : forbidden(); + if ((tail === "/actions/secrets" || tail === "/actions/organization-secrets") && method === "GET") + return scope.permissions.secrets ? json({ total_count: 0, secrets: [] }) : forbidden(); + if (tail === "/environments" && method === "GET") + return scope.permissions.actions ? json({ total_count: 0, environments: [] }) : forbidden(); const ruleset = tail.match(/^\/rulesets\/(\d+)$/); if (ruleset && method === "GET") { const found = repo.rulesets?.[Number(ruleset[1])]; diff --git a/cloudflare/github-app-manifest.json b/cloudflare/github-app-manifest.json index e5f3a66c..3b0b5bcf 100644 --- a/cloudflare/github-app-manifest.json +++ b/cloudflare/github-app-manifest.json @@ -14,6 +14,10 @@ "pull_requests": "write", "checks": "write", "statuses": "read", + "administration": "read", + "secrets": "read", + "actions": "read", + "environments": "read", "email_addresses": "read" }, "default_events": [ diff --git a/cloudflare/src/github/ci.ts b/cloudflare/src/github/ci.ts new file mode 100644 index 00000000..efc6cf81 --- /dev/null +++ b/cloudflare/src/github/ci.ts @@ -0,0 +1,227 @@ +// kix publishes job branches on the enrolled repository itself, where GitHub +// gives a branch's workflows the repository's secrets and default token. So +// pull-request CI must stay read-only: code no maintainer has reviewed meets a +// read-only GITHUB_TOKEN and no write credentials. This check reads the +// repository's Actions settings and reports what breaks that rule. It only +// warns: jobs still run, and a failed read becomes a warning of its own. + +/** The App permissions the check reads with, beyond metadata. */ +export const CI_PERMISSIONS = { + administration: "read", + secrets: "read", + actions: "read", + environments: "read", +} as const; +export type CIPermission = keyof typeof CI_PERMISSIONS; + +/** REST reads below `/repos//`, through the caller's scoped token. */ +export type CIReads = { get(path: string): Promise }; + +/** + * The reads one check may make, which keeps a setup page within Workers + * Free's 50 subrequests alongside its other reads. A check that runs out + * says so rather than reporting the rest as fine. + */ +export const READS = 30; +/** Thrown once the check has spent its reads. */ +const SPENT = Symbol("spent"); + +type Secrets = { total_count: number; secrets: { name: string }[] }; +type Environment = { + name: string; + deployment_branch_policy: { protected_branches: boolean; custom_branch_policies: boolean } | null; +}; +type Policy = { name: string; type?: string }; + +const PERMISSION_NAMES: Record = { + administration: "Administration", + secrets: "Secrets", + actions: "Actions", + environments: "Environments", +}; +const WILDCARD = /[*?[\]{}\\]/; +const QUEUE = /^gh-readonly-queue\/([^*?[\]{}\\]+)\/\*$/; + +function names(secrets: Secrets) { + const listed = secrets.secrets.map((secret) => secret.name); + const more = secrets.total_count - listed.length; + return listed.join(", ") + (more > 0 ? ` and ${more} more` : ""); +} +function count(n: number, noun: string) { + return `${n} ${noun}${n === 1 ? "" : "s"}`; +} + +/** + * What lets pull-request CI write, as sentences for the maintainer, or an + * empty list when nothing does. `granted` names the CI permissions the + * installation has accepted; checks needing another are reported unchecked. + */ +export async function ciProblems(reads: CIReads, granted: ReadonlySet): Promise { + const problems: string[] = []; + let left = READS; + let stopped = false; + const get = (path: string) => { + if (left <= 0) { + stopped = true; + throw SPENT; + } + left--; + return reads.get(path); + }; + // A failed or malformed read is a warning, never a failed page. + const read = async (path: string, what: string, valid: (value: T) => boolean): Promise => { + try { + const value = await get(path); + if (value && valid(value)) return value; + } catch (error) { + if (error === SPENT) return null; + } + problems.push(`kix could not read ${what}; review this project again later.`); + return null; + }; + const secretList = (value: Secrets) => typeof value.total_count === "number" && Array.isArray(value.secrets); + + const missing = (Object.keys(CI_PERMISSIONS) as CIPermission[]).filter((p) => !granted.has(p)); + if (missing.length) + problems.push( + `Accept the App's ${missing.map((p) => `${PERMISSION_NAMES[p]} (read)`).join(", ")} permission${missing.length === 1 ? "" : "s"} in its GitHub installation settings so kix can check ${[ + granted.has("administration") ? null : "the default workflow token", + granted.has("secrets") ? null : "repository and organization secrets", + granted.has("actions") && granted.has("environments") ? null : "environments", + ] + .filter(Boolean) + .join(", ")}.`, + ); + + if (granted.has("administration")) { + const token = await read<{ default_workflow_permissions: string }>( + "/actions/permissions/workflow", + "the default workflow token's permissions", + (value) => typeof value.default_workflow_permissions === "string", + ); + if (token && token.default_workflow_permissions !== "read") + problems.push( + "The default GITHUB_TOKEN can write. Set Settings → Actions → General → Workflow permissions to read repository contents and packages.", + ); + } + + if (granted.has("secrets")) { + const repository = await read("/actions/secrets?per_page=100", "repository secrets", secretList); + if (repository?.total_count) + problems.push( + `Every workflow, including those on kix's branches, receives ${count(repository.total_count, "repository secret")}: ${names(repository)}. Move them into environments limited to protected branches.`, + ); + const organization = await read( + "/actions/organization-secrets?per_page=100", + "organization secrets", + secretList, + ); + if (organization?.total_count) + problems.push( + `Every workflow, including those on kix's branches, receives ${count(organization.total_count, "organization secret")}: ${names(organization)}. Stop sharing them with this repository, or move them into environments limited to protected branches.`, + ); + } + + if (granted.has("actions") && granted.has("environments")) { + const listed = await read<{ total_count: number; environments: Environment[] }>( + "/environments?per_page=100", + "environments", + (value) => typeof value.total_count === "number" && Array.isArray(value.environments), + ); + // GitHub's first page of protected branches, read once and only when a + // policy names protected branches. A full page may leave some out, so a + // branch missing from it is looked up by name. + let page: Promise<{ names: Set; complete: boolean } | null> | undefined; + const protectedPage = () => + (page ??= read<{ name: string }[]>( + "/branches?protected=true&per_page=100", + "protected branches", + Array.isArray, + ).then( + (list) => list && { names: new Set(list.map((branch) => branch.name)), complete: list.length < 100 }, + )); + const lookups = new Map>(); + /** Whether GitHub reports the branch protected, or null when kix could not tell. */ + const isProtected = async (name: string): Promise => { + const known = await protectedPage(); + if (!known) return null; + if (known.names.has(name) || known.complete) return known.names.has(name); + let lookup = lookups.get(name); + if (!lookup) + lookups.set( + name, + (lookup = (async () => { + try { + const branch = await get<{ protected?: unknown }>( + `/branches/${name.split("/").map(encodeURIComponent).join("/")}`, + ); + if (typeof branch?.protected === "boolean") return branch.protected; + } catch (error) { + if (error === SPENT) return null; + // A branch that does not exist is not protected. + if ((error as { status?: unknown }).status === 404) return false; + } + problems.push(`kix could not read branch ${name}; review this project again later.`); + return null; + })()), + ); + return lookup; + }; + for (const environment of listed?.environments ?? []) { + if (left <= 0) { + stopped = true; + break; + } + const path = `/environments/${encodeURIComponent(environment.name)}`; + const policy = environment.deployment_branch_policy; + let reason: string | null = null; + if (!policy) reason = "lets any branch deploy"; + else if (policy.protected_branches) { + const known = await protectedPage(); + if (known && known.names.size === 0) + reason = + "allows only protected branches, but no branch is protected, so GitHub lets any branch deploy"; + } else if (policy.custom_branch_policies) { + const policies = await read<{ total_count: number; branch_policies: Policy[] }>( + `${path}/deployment-branch-policies?per_page=100`, + `environment ${environment.name}'s deployment branches`, + (value) => typeof value.total_count === "number" && Array.isArray(value.branch_policies), + ); + if (!policies) continue; + // Tags are written by people with write access, never by kix. A branch + // counts only when GitHub reports it protected, or it is the merge + // queue's own branch for one; a wildcard may match a branch kix writes. + const open: string[] = []; + let unknown = false; + for (const pattern of policies.branch_policies) { + if ((pattern.type ?? "branch") !== "branch") continue; + const branch = pattern.name.match(QUEUE)?.[1] ?? pattern.name; + const safe = WILDCARD.test(branch) ? false : await isProtected(branch); + // A failed read is already a warning, and running out of reads one below. + if (safe === null) unknown = true; + else if (!safe) open.push(pattern.name); + } + if (unknown) continue; + if (policies.total_count > policies.branch_policies.length) + reason = `lists ${policies.total_count} deployment branch and tag patterns, more than kix reads`; + else if (open.length) + reason = `lets ${open.join(", ")} deploy, which kix cannot confirm ${open.length === 1 ? "is a protected branch" : "are protected branches"}`; + } + if (!reason) continue; + const secrets = await read( + `${path}/secrets?per_page=100`, + `environment ${environment.name}'s secrets`, + secretList, + ); + if (secrets?.total_count) + problems.push( + `Environment ${environment.name} has ${count(secrets.total_count, "secret")} and ${reason}. Limit its deployment branches to protected branches.`, + ); + } + if (listed && (stopped || listed.total_count > listed.environments.length)) + problems.push( + `kix stopped after ${READS} reads of this repository's settings, before checking every environment. Limit each environment with secrets to protected branches.`, + ); + } + return problems; +} diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts index 304ee61e..31db0831 100644 --- a/cloudflare/src/github/service.ts +++ b/cloudflare/src/github/service.ts @@ -14,6 +14,7 @@ import { import { checksState, latestContexts } from "../../../src/github/checks"; import { cleanAutoMergeRefusal, immediateMerge, queueHead } from "../../../src/github/merge"; import { osiApproved } from "../licenses"; +import { CI_PERMISSIONS, type CIPermission, ciProblems } from "./ci"; import { OBSERVED, type Observed, observedGap, REVIEW_CHECK, unreviewed } from "./review"; import { GOAL_LIMIT, SETUP_BRANCH, STUBS, planCheck, planned, setupGoal, setupPull, treeCheckout } from "./setup"; import { positiveId } from "./webhook"; @@ -33,7 +34,7 @@ export interface GitHubRepository { name: string; id: number; } -type Permission = "metadata" | "contents" | "issues" | "pull_requests" | "checks" | "statuses"; +type Permission = "metadata" | "contents" | "issues" | "pull_requests" | "checks" | "statuses" | CIPermission; type Permissions = Partial>; /** What reading a project's observations needs. */ const observing: Permissions = { @@ -112,7 +113,8 @@ export class NotOpen extends GitHubError { /** The default branch has no Quest files, so the project sets up Quest before enabling. */ export class NoQuestFiles extends GitHubError { - constructor() { + /** `ci` is the setup page's pull-request CI check (`./ci.ts`), when it ran. */ + constructor(readonly ci: string[] = []) { super("Quest files are required. Open the setup pull request first."); } } @@ -204,6 +206,8 @@ export type ProjectPreview = { licenseError: string | null; quests: { path: string; title: string }[]; state: ContributionState | null; + /** What lets pull-request CI write (`./ci.ts`); empty when nothing does. */ + ci: string[]; }; type Tree = { truncated: boolean; tree: { path: string; type: string; mode: string; sha: string }[] }; @@ -331,10 +335,12 @@ export class GitHubService { } } + /** `optional` permissions join the token when the installation grants them; `action` sees the token's scope. */ async #withInstallation( repo: Pick & { id?: number }, permissions: Permissions, - action: (token: string, installationId: number) => Promise, + action: (token: string, installationId: number, scope: Permissions) => Promise, + optional: Permissions = {}, ) { if (!name(repo.owner) || !name(repo.name)) throw new GitHubError("Invalid repository"); const path = `/repos/${repo.owner}/${repo.name}`; @@ -354,6 +360,10 @@ export class GitHubService { if (!granted || (level === "write" && granted !== "write")) throw new GitHubError(`GitHub ${permission} permission required`); } + for (const [permission, level] of Object.entries(optional)) { + const granted = installation.permissions[permission as Permission]; + if (granted && (level === "read" || granted === "write")) scope[permission as Permission] = level; + } const minted = await this.#api<{ token: string }>( this.#jwt(), `/app/installations/${installation.id}/access_tokens`, @@ -366,7 +376,7 @@ export class GitHubService { if (!minted.token) throw new GitHubError("Installation token unavailable"); try { await this.#gate(installation.id); - return await action(minted.token, installation.id); + return await action(minted.token, installation.id, scope); } finally { // Do not turn a completed mutation into a retryable error if cleanup // fails. GitHub also expires installation tokens after one hour. @@ -381,16 +391,22 @@ export class GitHubService { async #withRepository( repo: GitHubRepository, permissions: Permissions, - action: (token: string, current: Repository, installationId: number) => Promise, + action: (token: string, current: Repository, installationId: number, scope: Permissions) => Promise, allowUnavailable = false, + optional: Permissions = {}, ) { const path = repositoryPath(repo); - return this.#withInstallation(repo, permissions, async (token, installation) => { - const current = await this.#api(token, path); - this.#repository(repo, current, allowUnavailable); - await this.#gate(installation); - return action(token, current, installation); - }); + return this.#withInstallation( + repo, + permissions, + async (token, installation, scope) => { + const current = await this.#api(token, path); + this.#repository(repo, current, allowUnavailable); + await this.#gate(installation); + return action(token, current, installation, scope); + }, + optional, + ); } /** @@ -454,18 +470,20 @@ export class GitHubService { repo: GitHubRepository, userId: string, permissions: Permissions, - action: (token: string, current: Repository, installationId: number) => Promise, + action: (token: string, current: Repository, installationId: number, scope: Permissions) => Promise, allowUnavailable = false, + optional: Permissions = {}, ) { return this.#withRepository( repo, permissions, - async (token, current, installationId) => { + async (token, current, installationId, scope) => { await this.#maintainer(token, repo, userId); await this.#gate(installationId); - return action(token, current, installationId); + return action(token, current, installationId, scope); }, allowUnavailable, + optional, ); } @@ -781,7 +799,7 @@ export class GitHubService { repo: GitHubRepository, current: Repository, installation: number, - ): Promise { + ): Promise> { const snapshot = await this.#snapshot(token, repo, current); return { repository: repo, @@ -1235,9 +1253,33 @@ export class GitHubService { return commits.size === count && (count === 0 || commits.has(revision)); } - previewProject(repo: GitHubRepository, userId: string) { - return this.#authorized(repo, userId, { contents: "read" }, (token, current, installation) => - this.#preview(token, repo, current, installation), + /** + * The setup page's preview, with the pull-request CI check (`./ci.ts`) on + * the same token. A repository without Quest files throws `NoQuestFiles` + * carrying the check, since merging its setup pull request enables it. + */ + previewProject(repo: GitHubRepository, userId: string): Promise { + return this.#authorized( + repo, + userId, + { contents: "read" }, + async (token, current, installation, scope) => { + const ci = () => + ciProblems( + { get: (path) => this.#api(token, `${repositoryPath(repo)}${path}`) }, + new Set((Object.keys(CI_PERMISSIONS) as CIPermission[]).filter((p) => scope[p])), + ); + let preview; + try { + preview = await this.#preview(token, repo, current, installation); + } catch (error) { + if (error instanceof NoQuestFiles) throw new NoQuestFiles(await ci()); + throw error; + } + return { ...preview, ci: await ci() }; + }, + false, + CI_PERMISSIONS, ); } diff --git a/cloudflare/src/index.tsx b/cloudflare/src/index.tsx index fa2da56a..a734cd5a 100644 --- a/cloudflare/src/index.tsx +++ b/cloudflare/src/index.tsx @@ -183,7 +183,7 @@ app.get("/projects/github/:owner/:repository", async (c) => { return page( c, { title: `${repository.owner}/${repository.name} setup`, description: "Set up Quest." }, - , + , ); } return page( diff --git a/cloudflare/src/projects.tsx b/cloudflare/src/projects.tsx index 7b932e02..3a8d9b69 100644 --- a/cloudflare/src/projects.tsx +++ b/cloudflare/src/projects.tsx @@ -205,8 +205,45 @@ function GoalField() { ); } +/** The pull-request CI check's warnings (`./github/ci.ts`), which never block enabling. */ +function CIPanel(props: { problems: string[] }) { + return ( +
+

Pull-request CI

+

+ kix publishes job branches on this repository, where GitHub gives their workflows the repository's + secrets and default token. Keep that CI read-only, so code no maintainer has reviewed meets only a + read-only GITHUB_TOKEN and no write credentials. +

+ {props.problems.length ? ( +
    + {props.problems.map((problem) => ( +
  • {problem}
  • + ))} +
+ ) : ( +

+ The default token is read-only, no repository or organization secrets reach workflows, and + environment secrets deploy only from protected branches. +

+ )} +

+ Jobs still run either way. kix does not read workflow files, so also keep write{" "} + permissions: and secrets out of jobs that pull requests and pushes to unprotected branches + trigger, including pull_request_target and workflow_run jobs that check out a + pull request's head. +

+
+ ); +} + /** Enrolling a repository with no Quest files: the setup pull request's one question. */ -export function QuestSetupPage(props: { repository: { id: number; owner: string; name: string }; error?: string }) { +export function QuestSetupPage(props: { + repository: { id: number; owner: string; name: string }; + /** The pull-request CI check's warnings, when it ran. */ + ci?: string[]; + error?: string; +}) { const repo = props.repository; return (
@@ -233,6 +270,7 @@ export function QuestSetupPage(props: { repository: { id: number; owner: string;

+ {props.ci && } ); } @@ -366,6 +404,7 @@ export function ProjectSetupPage(props: { preview: ProjectPreview; enrollment: E )} +

Ready quests

{props.enrollment && ( diff --git a/cloudflare/test/projects.test.ts b/cloudflare/test/projects.test.ts index 3e7a7bf3..abc873b6 100644 --- a/cloudflare/test/projects.test.ts +++ b/cloudflare/test/projects.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { app } from "../src/index"; import { readBoard } from "../src/board/model"; import { type ActivityPage, associatedQuest, RETENTION, readActivity } from "../src/github/state"; +import { CI_PERMISSIONS, READS } from "../src/github/ci"; import { GitHubService } from "../src/github/service"; import { privateKey } from "./github-key"; import { identity, oauth, origin } from "./login-fixtures"; @@ -11,6 +12,20 @@ import { outsider, owner } from "./owners"; import { delivery } from "./project-fixtures"; const testEnv = { ...env, GITHUB_APP_PRIVATE_KEY: privateKey }; +/** A repository's Actions settings, as the CI check reads them. */ +type CISettings = { + token?: "read" | "write"; + repository?: string[]; + organization?: string[]; + /** Branches GitHub reports protected, listing the first hundred; `trunk` by default. */ + protected?: string[]; + /** Other branches that exist. */ + branches?: string[]; + /** `policy` is null for any branch, "protected", or branch patterns, with `tag:` marking a tag pattern. */ + environments?: { name: string; policy: null | "protected" | string[]; secrets: number }[]; + /** Paths below the repository that GitHub refuses. */ + fail?: string[]; +}; const repo = { owner: "upstream", name: "project", id: 10 }; const commit = "a".repeat(40); const tree = "b".repeat(40); @@ -68,6 +83,8 @@ function github( licenseStatus?: number; licenseFails?: boolean; onLicenseRead?: () => void; + /** The repository's Actions settings; the installation grants the CI check's permissions only with this. */ + ci?: CISettings; } = {}, ) { const repositoryName = options.repositoryName ?? repo.name; @@ -125,6 +142,7 @@ function github( pull_requests: "read", checks: "read", statuses: "read", + ...(options.ci && CI_PERMISSIONS), }, }, { status: options.missing ? 404 : 200 }, @@ -154,6 +172,70 @@ function github( } if (path.startsWith("/user/")) return Response.json({ id: Number(path.split("/").at(-1)), login: "maintainer" }); + if (options.ci && path.startsWith(`${repositoryBase}/`)) { + const ci = options.ci; + const rest = path.slice(repositoryBase.length); + const failed = ci.fail?.includes(rest) ? Response.json({ message: "Forbidden" }, { status: 403 }) : null; + const secrets = (names: string[]) => ({ + total_count: names.length, + secrets: names.map((name) => ({ name })), + }); + if (rest === "/actions/permissions/workflow") + return failed ?? Response.json({ default_workflow_permissions: ci.token ?? "read" }); + if (rest === "/actions/secrets") return failed ?? Response.json(secrets(ci.repository ?? [])); + if (rest === "/actions/organization-secrets") + return failed ?? Response.json(secrets(ci.organization ?? [])); + const protectedBranches = ci.protected ?? ["trunk"]; + if (rest === "/branches") { + expect(url.searchParams.get("protected")).toBe("true"); + return ( + failed ?? Response.json(protectedBranches.slice(0, 100).map((name) => ({ name, protected: true }))) + ); + } + const branch = rest.match(/^\/branches\/(.+)$/)?.[1]; + if (branch !== undefined) { + const name = branch.split("/").map(decodeURIComponent).join("/"); + if (!protectedBranches.includes(name) && !ci.branches?.includes(name)) + return Response.json({ message: "Branch not found" }, { status: 404 }); + return failed ?? Response.json({ name, protected: protectedBranches.includes(name) }); + } + const environments = ci.environments ?? []; + if (rest === "/environments") + return ( + failed ?? + Response.json({ + total_count: environments.length, + environments: environments.slice(0, 100).map(({ name, policy }) => ({ + name, + deployment_branch_policy: + policy === null + ? null + : { + protected_branches: policy === "protected", + custom_branch_policies: Array.isArray(policy), + }, + })), + }) + ); + const environment = rest.match(/^\/environments\/([^/]+)\/(secrets|deployment-branch-policies)$/); + const found = environment && environments.find((e) => e.name === decodeURIComponent(environment[1]!)); + if (found && environment![2] === "secrets") + return failed ?? Response.json(secrets(Array.from({ length: found.secrets }, (_, i) => `S${i}`))); + if (found && Array.isArray(found.policy)) + return ( + failed ?? + Response.json({ + total_count: found.policy.length, + branch_policies: found.policy + .slice(0, 100) + .map((name) => + name.startsWith("tag:") + ? { name: name.slice(4), type: "tag" } + : { name, type: "branch" }, + ), + }) + ); + } if (path.endsWith("/permission")) { return Response.json({ user: { id: options.account ?? 7007 }, @@ -280,6 +362,184 @@ describe("project enrollment", () => { expect(calls.some((call) => call.path === `/repos/upstream/project/git/trees/${tree}?recursive=1`)).toBe(true); expect(calls.at(-1)?.path).toBe("/installation/token"); }); + it("asks for the CI check's permissions an installation has not accepted, without failing the preview", async () => { + const { service, calls } = github(); + expect((await service.previewProject(repo, "enrollment-test")).ci).toEqual([ + "Accept the App's Administration (read), Secrets (read), Actions (read), Environments (read) permissions in its GitHub installation settings so kix can check the default workflow token, repository and organization secrets, environments.", + ]); + expect(calls.some((call) => call.path.includes("/actions/") || call.path.includes("/environments"))).toBe( + false, + ); + }); + it("finds nothing to fix in read-only pull-request CI", async () => { + const { service, calls } = github({ + ci: { + environments: [ + { name: "release", policy: "protected", secrets: 2 }, + { name: "land", policy: ["trunk", "gh-readonly-queue/trunk/*", "tag:v*"], secrets: 1 }, + { name: "preview", policy: null, secrets: 0 }, + ], + }, + }); + expect((await service.previewProject(repo, "enrollment-test")).ci).toEqual([]); + expect(calls.filter((call) => call.path.endsWith("/access_tokens")).at(-1)?.body.permissions).toEqual({ + metadata: "read", + contents: "read", + ...CI_PERMISSIONS, + }); + // Protected branches are read once however many policies name them, and + // an environment's secrets only when its policy lets an unprotected branch deploy. + expect(calls.filter((call) => call.path.startsWith("/repos/upstream/project/branches"))).toHaveLength(1); + expect(calls.filter((call) => call.path.includes("secrets?")).map((call) => call.path)).toEqual([ + "/repos/upstream/project/actions/secrets?per_page=100", + "/repos/upstream/project/actions/organization-secrets?per_page=100", + "/repos/upstream/project/environments/preview/secrets?per_page=100", + ]); + }); + it("warns about each way pull-request CI can write", async () => { + const { service } = github({ + ci: { + token: "write", + repository: ["NPM_TOKEN"], + organization: ["DISCORD_WEBHOOK", "CACHIX"], + protected: ["trunk"], + environments: [ + { name: "open", policy: null, secrets: 1 }, + { name: "wild", policy: ["release/*", "tag:v*"], secrets: 2 }, + { name: "loose", policy: ["trunk", "staging"], secrets: 1 }, + { name: "queue", policy: ["gh-readonly-queue/staging/*"], secrets: 1 }, + ], + }, + }); + expect((await service.previewProject(repo, "enrollment-test")).ci).toEqual([ + "The default GITHUB_TOKEN can write. Set Settings → Actions → General → Workflow permissions to read repository contents and packages.", + "Every workflow, including those on kix's branches, receives 1 repository secret: NPM_TOKEN. Move them into environments limited to protected branches.", + "Every workflow, including those on kix's branches, receives 2 organization secrets: DISCORD_WEBHOOK, CACHIX. Stop sharing them with this repository, or move them into environments limited to protected branches.", + "Environment open has 1 secret and lets any branch deploy. Limit its deployment branches to protected branches.", + "Environment wild has 2 secrets and lets release/* deploy, which kix cannot confirm is a protected branch. Limit its deployment branches to protected branches.", + "Environment loose has 1 secret and lets staging deploy, which kix cannot confirm is a protected branch. Limit its deployment branches to protected branches.", + "Environment queue has 1 secret and lets gh-readonly-queue/staging/* deploy, which kix cannot confirm is a protected branch. Limit its deployment branches to protected branches.", + ]); + }); + it("looks up a named branch that a full page of protected branches leaves out", async () => { + const many = Array.from({ length: 100 }, (_, i) => `keep/${i}`); + const { service, calls } = github({ + ci: { + protected: [...many, "release/v1"], + branches: ["staging"], + environments: [ + { name: "ship", policy: ["release/v1", "keep/0"], secrets: 1 }, + { name: "stage", policy: ["staging", "missing"], secrets: 1 }, + { name: "again", policy: ["release/v1"], secrets: 1 }, + ], + }, + }); + expect((await service.previewProject(repo, "enrollment-test")).ci).toEqual([ + "Environment stage has 1 secret and lets staging, missing deploy, which kix cannot confirm are protected branches. Limit its deployment branches to protected branches.", + ]); + // Each branch the page left out is looked up once. + expect( + calls.filter((call) => /\/branches\/./.test(call.path)).map((call) => call.path.split("/branches/")[1]), + ).toEqual(["release/v1", "staging", "missing"]); + }); + it("does not confirm a deployment policy with more patterns than one page holds", async () => { + const { service } = github({ + ci: { + environments: [{ name: "big", policy: Array.from({ length: 101 }, () => "trunk"), secrets: 1 }], + }, + }); + expect((await service.previewProject(repo, "enrollment-test")).ci).toEqual([ + "Environment big has 1 secret and lists 101 deployment branch and tag patterns, more than kix reads. Limit its deployment branches to protected branches.", + ]); + }); + it("warns that a protected-branches policy admits every branch while none is protected", async () => { + const { service } = github({ + ci: { protected: [], environments: [{ name: "deploy", policy: "protected", secrets: 1 }] }, + }); + expect((await service.previewProject(repo, "enrollment-test")).ci).toEqual([ + "Environment deploy has 1 secret and allows only protected branches, but no branch is protected, so GitHub lets any branch deploy. Limit its deployment branches to protected branches.", + ]); + }); + it("reports a failed CI read as a warning and checks the rest", async () => { + const { service } = github({ + ci: { repository: ["TOKEN"], fail: ["/actions/permissions/workflow", "/environments"] }, + }); + const preview = await service.previewProject(repo, "enrollment-test"); + expect(preview.quests).toHaveLength(1); + expect(preview.ci).toEqual([ + "kix could not read the default workflow token's permissions; review this project again later.", + "Every workflow, including those on kix's branches, receives 1 repository secret: TOKEN. Move them into environments limited to protected branches.", + "kix could not read environments; review this project again later.", + ]); + }); + it.each([ + { + name: "many environments", + ci: { + environments: Array.from({ length: 20 }, (_, i) => ({ + name: `env-${i}`, + policy: ["feature/*"], + secrets: 1, + })), + }, + }, + { + name: "branch lookups past a full page of protected branches", + ci: { + protected: Array.from({ length: 100 }, (_, i) => `keep/${i}`), + environments: [ + { name: "wide", policy: Array.from({ length: 60 }, (_, i) => `other/${i}`), secrets: 1 }, + { name: "open", policy: null, secrets: 1 }, + ], + }, + }, + ])("stops a CI check with $name within Workers Free's 50 external subrequests", async ({ ci }) => { + const { service, calls } = github({ ci, subrequestLimit: 50 }); + await service.resolveRepository(repo.owner, repo.name); + const preview = await service.previewProject(repo, "enrollment-test"); + expect(preview.ci.at(-1)).toBe( + `kix stopped after ${READS} reads of this repository's settings, before checking every environment. Limit each environment with secrets to protected branches.`, + ); + expect(preview.ci.filter((problem) => problem.startsWith("kix could not"))).toEqual([]); + expect(calls.length).toBeLessThanOrEqual(50); + }); + it("does not report a check that spent its last read on its last environment as stopped", async () => { + // Four reads before environments, then one for each one's secrets. + const environments = Array.from({ length: READS - 4 }, (_, i) => ({ + name: `env-${i}`, + policy: null, + secrets: 1, + })); + const { service } = github({ ci: { environments } }); + const { ci } = await service.previewProject(repo, "enrollment-test"); + expect(ci).toHaveLength(READS - 4); + expect(ci.filter((problem) => problem.startsWith("kix stopped"))).toEqual([]); + }); + it("shows the CI check on the project's setup page", async () => { + const who = identity("github"); + const { session } = await oauth(who); + const { request } = github({ account: Number(who.id), ci: { token: "write" } }); + vi.spyOn(globalThis, "fetch").mockImplementation(request as typeof fetch); + const html = await ( + await app.request(`${origin}/projects/github/upstream/project`, { headers: { Cookie: session! } }, testEnv) + ).text(); + expect(html).toContain("Pull-request CI"); + expect(html).toContain("The default GITHUB_TOKEN can write."); + // Warnings never block enabling contributions. + expect(html).not.toContain("disabled"); + }); + it("shows the CI check beside the setup pull request's form, since merging it enables contributions", async () => { + const who = identity("github"); + const { session } = await oauth(who); + const { request } = github({ account: Number(who.id), noQuests: true, ci: { repository: ["NPM_TOKEN"] } }); + vi.spyOn(globalThis, "fetch").mockImplementation(request as typeof fetch); + const html = await ( + await app.request(`${origin}/projects/github/upstream/project`, { headers: { Cookie: session! } }, testEnv) + ).text(); + expect(html).toContain("Open the setup pull request"); + expect(html).toContain("Pull-request CI"); + expect(html).toContain("receives 1 repository secret: NPM_TOKEN"); + }); it.each(["AGENTS.md", "CLAUDE.md"])("ignores root and nested %s instructions like the CLI", async (filename) => { const { service, calls } = github({ documents: [ diff --git a/cloudflare/test/setup.test.ts b/cloudflare/test/setup.test.ts index 516633c2..9b933e44 100644 --- a/cloudflare/test/setup.test.ts +++ b/cloudflare/test/setup.test.ts @@ -96,6 +96,8 @@ describe("the setup pull request", () => { const html = await form.text(); expect(html).toContain("Set up Quest"); expect(html).toContain('name="goal"'); + // The fake's CI settings are read-only, so the CI check finds nothing. + expect(html).toContain("The default token is read-only"); const response = await post("/setup", { id: "10", goal: "Ship a fast parser.\n# Not a heading" }); expect(response.status).toBe(303); diff --git a/quest/m0/kix/operator-setup.md b/quest/m0/kix/operator-setup.md index 9c4b15e9..06adb86a 100644 --- a/quest/m0/kix/operator-setup.md +++ b/quest/m0/kix/operator-setup.md @@ -40,6 +40,17 @@ bounded test PR verify the integration before contributors are invited. [moq-dev/quest#190](https://github.com/moq-dev/quest/pull/190) handles either way (adopt it if open, open a new one if closed) but tests only against the fake. Added 2026-10-22 (user). +- Each pilot's setup page shows no Pull-request CI warning before + contributors are invited (Read-only pull-request CI in + `cloudflare/GITHUB.md`; moved from the read-only CI quest on 2026-11-02). + Read with `gh api` that day: both default workflow tokens are read-only, + but the moq-dev organization shares its `DISCORD_WEBHOOK` secret with both + pilots, and moq-dev/moq keeps 18 repository secrets, including the + `MBX_R2_*` pair its `check.yml` passes to `pull_request` checks; moq-dev/moq + tracks moving them in + [read-only secrets](https://github.com/moq-dev/moq/blob/main/quest/m0/ci/read-only-secrets.md). + Its environments name only protected branches, its merge queue's, and tags. + Accept the App's new CI-check permissions on both installations first. - Rehearse on the operator's own runner and an explicitly selected operator account. - Record nonsecret setup/run instructions and deployment identifiers. diff --git a/quest/m1/kix/README.md b/quest/m1/kix/README.md index 1b83b236..9a3ca15e 100644 --- a/quest/m1/kix/README.md +++ b/quest/m1/kix/README.md @@ -15,6 +15,5 @@ maintainers, so they need none of this first. ## Required -- [Read-only CI](/quest/m1/kix/ci-read-only.md) - enrolled repositories keep pull-request CI read-only, checked at enrollment, with every trust boundary written down - [Contributor sandboxing](/quest/m1/kix/sandbox/README.md) - contributors' PR heads build in a VM, on stock Ubuntu and verified on macOS - [Onboarding guide](/quest/m1/kix/guide.md) - a complete maintainer path, from runner setup through a reviewed PR diff --git a/quest/m1/kix/ci-read-only.md b/quest/m1/kix/ci-read-only.md deleted file mode 100644 index d8ab5649..00000000 --- a/quest/m1/kix/ci-read-only.md +++ /dev/null @@ -1,59 +0,0 @@ -# [M] Require read-only pull-request CI - -## Goal - -Every kix trust boundary is written down, and enrolled repositories keep -pull-request CI read-only. kix publishes job branches on the repository -itself, where GitHub gives a branch's workflows the repository's secrets and -default token. So code no maintainer has reviewed must meet only a read-only -`GITHUB_TOKEN` and no write credentials. kix checks this when a repository is -enrolled and warns the maintainer about what to fix. `cloudflare/GITHUB.md` -states the trust rule for each stage of a job. - -## Plan - -Decided 2026-10-28 (user, /quest-plan trust audit started from -moq-dev/quest#246's Codex Security P1): - -- **Rule over gates.** Read-only CI replaces a service fork or a per-push - review gate as the answer to agent-written code reaching CI. Fork PRs - already get no secrets; same-repository branches need the repository's own - settings to match. The guide states the rule for agents - ([moq-dev/quest#263](https://github.com/moq-dev/quest/pull/263)). -- **Check and warn, don't refuse.** At enrollment, and on the project's - settings page, kix reports each problem it finds: - - the default workflow token can write; - - Actions secrets at repository level; - - an environment with secrets but no protected-branch deployment policy. - - Jobs still run. Check which App permission reads secret and environment - metadata; adding one is an operator step in `cloudflare/GITHUB.md`. -- **Runner claims are the owner's attestation.** Jobs go only to the - requester's own runners, so a runner's self-declared `vm` flag and its - request to mark a PR ready are its owner's statements. kix doesn't verify - them. Under read-only CI, a runner marking its PR ready starts only harmless - CI. -- Rewrite `cloudflare/GITHUB.md`'s trust section as a stage-by-stage table: - intake, job creation, routing, devShell, agent run, publication, CI, review, - and land. For each stage, name what is trusted and why. The devShell stage - trusts a PR head only when current maintainers signed its commits or one - approved that exact head in the inbox (`head_approvals`). Fix the - contradiction between `cloudflare/README.md` ("route only to Linux runners - that declare VM builds") and the root README (every runner refuses untrusted - jobs). -- `SETUP.md` recommends the same settings when a repository adopts Quest, - beside its advice to skip CI on drafts. -- The pilot repositories pass the check before contributors are invited: - moq-dev/quest's `land` environment was limited to `main` on 2026-10-28, - and moq-dev/moq tracks its fix in - [read-only secrets](https://github.com/moq-dev/moq/blob/main/quest/m0/ci/read-only-secrets.md). -- Noted 2026-11-01 (user, /quest-plan, from - [moq-dev/quest#246](https://github.com/moq-dev/quest/pull/246)): - moq-dev/moq's `.github/workflows/check.yml` passes the - `MBX_R2_ACCESS_KEY_ID` and `MBX_R2_SECRET_ACCESS_KEY` secrets to its - `pull_request` checks. They must move to an environment limited to - protected branches before moq enrolls. - -## Related - -- [Operator setup](/quest/m0/kix/operator-setup.md) - pilot repositories should pass this check