From 6875dabdd72a62dcee3ea3e182bd6e1175cbcef3 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Mon, 2 Nov 2026 19:43:25 -0800
Subject: [PATCH 1/8] chore(quest): claim quest/m1/kix/ci-read-only.md
Quest-Claim: a736d3d4522d1a65
From 550ecf12a6c6b3c2b04d4eec9d28464490593718 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Mon, 2 Nov 2026 19:51:23 -0800
Subject: [PATCH 2/8] feat(kix): warn when an enrolled repository's
pull-request CI can write
Co-Authored-By: Claude Opus 5.5
---
cloudflare/github-app-manifest.json | 4 +
cloudflare/src/github/ci.ts | 169 +++++++++++++++++++++++++++
cloudflare/src/github/service.ts | 63 +++++++---
cloudflare/src/projects.tsx | 26 +++++
cloudflare/test/projects.test.ts | 173 ++++++++++++++++++++++++++++
5 files changed, 418 insertions(+), 17 deletions(-)
create mode 100644 cloudflare/src/github/ci.ts
diff --git a/cloudflare/github-app-manifest.json b/cloudflare/github-app-manifest.json
index 1207e8cd..f68699bb 100644
--- a/cloudflare/github-app-manifest.json
+++ b/cloudflare/github-app-manifest.json
@@ -14,6 +14,10 @@
"pull_requests": "write",
"checks": "read",
"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..01273ce8
--- /dev/null
+++ b/cloudflare/src/github/ci.ts
@@ -0,0 +1,169 @@
+// 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 };
+
+/**
+ * Environments examined per check. Each costs up to two reads, which keeps a
+ * setup page within Workers Free's 50 subrequests alongside its other reads.
+ */
+export const ENVIRONMENTS = 12;
+
+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[] = [];
+ // 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 reads.get(path);
+ if (value && valid(value)) return value;
+ } catch {}
+ 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),
+ );
+ // Read once, only when a policy names protected branches.
+ let protectedBranches: Promise | null> | undefined;
+ const branches = () =>
+ (protectedBranches ??= read<{ name: string }[]>(
+ "/branches?protected=true&per_page=100",
+ "protected branches",
+ Array.isArray,
+ ).then((list) => list && new Set(list.map((branch) => branch.name))));
+ for (const environment of listed?.environments.slice(0, ENVIRONMENTS) ?? []) {
+ 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 branches();
+ if (known && known.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<{ branch_policies: Policy[] }>(
+ `${path}/deployment-branch-policies?per_page=100`,
+ `environment ${environment.name}'s deployment branches`,
+ (value) => 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 patterns = policies.branch_policies.filter((p) => (p.type ?? "branch") === "branch");
+ const fixed = patterns.map((p) => p.name.match(QUEUE)?.[1] ?? p.name);
+ const known = fixed.some((name) => !WILDCARD.test(name)) ? await branches() : new Set();
+ if (!known) continue;
+ const open = patterns.filter((_, i) => WILDCARD.test(fixed[i]!) || !known.has(fixed[i]!));
+ if (open.length)
+ reason = `lets ${open.map((p) => p.name).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 && listed.total_count > ENVIRONMENTS)
+ problems.push(
+ `kix checked only the first ${ENVIRONMENTS} of ${listed.total_count} environments. Limit the rest to protected branches too.`,
+ );
+ }
+ return problems;
+}
diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts
index 0cdefb16..85ee3553 100644
--- a/cloudflare/src/github/service.ts
+++ b/cloudflare/src/github/service.ts
@@ -2,6 +2,7 @@ import { createHash, createPrivateKey, sign } from "node:crypto";
import { HOLD_PREFIX, isHoldLabel, latestHold, heldToken, check, isQuest, parse, ready } from "quest/core";
import { cleanAutoMergeRefusal, immediateMerge } from "../../../src/github/merge";
import { osiApproved } from "../licenses";
+import { CI_PERMISSIONS, type CIPermission, ciProblems } from "./ci";
import { unreviewed } from "./review";
import { positiveId } from "./webhook";
import {
@@ -20,7 +21,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 = {
@@ -148,6 +149,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 }[] };
@@ -263,10 +266,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}`;
@@ -286,6 +291,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`,
@@ -298,7 +307,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.
@@ -313,16 +322,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,
+ );
}
/**
@@ -386,18 +401,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,
);
}
@@ -700,7 +717,7 @@ export class GitHubService {
repo: GitHubRepository,
current: Repository,
installation: number,
- ): Promise {
+ ): Promise> {
const snapshot = await this.#snapshot(token, repo, current);
return {
repository: repo,
@@ -1096,9 +1113,21 @@ 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. */
+ previewProject(repo: GitHubRepository, userId: string): Promise {
+ return this.#authorized(
+ repo,
+ userId,
+ { contents: "read" },
+ async (token, current, installation, scope) => ({
+ ...(await this.#preview(token, repo, current, installation)),
+ ci: await ciProblems(
+ { get: (path) => this.#api(token, `${repositoryPath(repo)}${path}`) },
+ new Set((Object.keys(CI_PERMISSIONS) as CIPermission[]).filter((p) => scope[p])),
+ ),
+ }),
+ false,
+ CI_PERMISSIONS,
);
}
diff --git a/cloudflare/src/projects.tsx b/cloudflare/src/projects.tsx
index 6eb2acc5..9c012098 100644
--- a/cloudflare/src/projects.tsx
+++ b/cloudflare/src/projects.tsx
@@ -230,6 +230,32 @@ export function ProjectSetupPage(props: { preview: ProjectPreview; enrollment: E
>
)}
+
+ 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.
+
+ {preview.ci.length ? (
+
+ {preview.ci.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.
+
+
Ready quests
{props.enrollment && (
diff --git a/cloudflare/test/projects.test.ts b/cloudflare/test/projects.test.ts
index 5aa6b833..4861355e 100644
--- a/cloudflare/test/projects.test.ts
+++ b/cloudflare/test/projects.test.ts
@@ -4,12 +4,25 @@ 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, ENVIRONMENTS } from "../src/github/ci";
import { GitHubService } from "../src/github/service";
import { privateKey } from "./github-key";
import { identity, oauth, origin } from "./login-fixtures";
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; `trunk` by default. */
+ protected?: 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);
@@ -65,6 +78,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;
@@ -122,6 +137,7 @@ function github(
pull_requests: "read",
checks: "read",
statuses: "read",
+ ...(options.ci && CI_PERMISSIONS),
},
},
{ status: options.missing ? 404 : 200 },
@@ -151,6 +167,56 @@ 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 ?? []));
+ if (rest === "/branches") {
+ expect(url.searchParams.get("protected")).toBe("true");
+ return failed ?? Response.json((ci.protected ?? ["trunk"]).map((name) => ({ name, protected: true })));
+ }
+ 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.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 },
@@ -271,6 +337,113 @@ 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("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("checks a bounded number of environments within Workers Free's 50 external subrequests", async () => {
+ const environments = Array.from({ length: ENVIRONMENTS + 3 }, (_, i) => ({
+ name: `env-${i}`,
+ policy: ["feature/*"],
+ secrets: 1,
+ }));
+ const { service, calls } = github({ ci: { environments }, subrequestLimit: 50 });
+ await service.resolveRepository(repo.owner, repo.name);
+ const { ci } = await service.previewProject(repo, "enrollment-test");
+ expect(ci).toHaveLength(ENVIRONMENTS + 1);
+ expect(ci.at(-1)).toBe(
+ `kix checked only the first ${ENVIRONMENTS} of ${ENVIRONMENTS + 3} environments. Limit the rest to protected branches too.`,
+ );
+ expect(calls.length).toBeLessThanOrEqual(50);
+ });
+ 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.each(["AGENTS.md", "CLAUDE.md"])("ignores root and nested %s instructions like the CLI", async (filename) => {
const { service, calls } = github({
documents: [
From e2fe7ff63f0afca697f305e2ce7fee048e5fafb2 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Mon, 2 Nov 2026 19:53:46 -0800
Subject: [PATCH 3/8] docs(kix): write down trust by stage and the read-only CI
rule
Co-Authored-By: Claude Opus 5.5
---
README.md | 5 +-
SETUP.md | 11 +++++
cloudflare/GITHUB.md | 86 +++++++++++++++++++++++++++++-----
cloudflare/README.md | 8 +++-
cloudflare/dev/github.ts | 31 ++++++++++--
quest/m0/kix/operator-setup.md | 11 +++++
quest/m1/kix/README.md | 1 -
quest/m1/kix/ci-read-only.md | 58 -----------------------
8 files changed, 131 insertions(+), 80 deletions(-)
delete mode 100644 quest/m1/kix/ci-read-only.md
diff --git a/README.md b/README.md
index 31c9c53c..ff599305 100644
--- a/README.md
+++ b/README.md
@@ -1115,8 +1115,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 refuses 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 ff211298..fd7246cc 100644
--- a/SETUP.md
+++ b/SETUP.md
@@ -81,6 +81,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 2f722846..fda047a7 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: read | Observe checks on a commit | Check reads only |
| 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, workflow, or checks-write permissions are requested. These installation grants
+administration-write, workflow, or checks-write 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. A head kix pushed to is never trusted | The runner's host Nix evaluates the flake outside the sandbox, and "kix published it" proves only that a runner sent it ([head approval](../quest/m0/kix/sandbox/head-approval.md) plans an explicit per-head approval) |
+| 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
@@ -240,8 +264,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.
@@ -263,15 +288,45 @@ 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, 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 checks the first 12 environments, so the page
+stays within a Worker's subrequest budget, and says when there are more. 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
@@ -337,11 +392,16 @@ reconcile current provider state rather than replay issue/PR payloads as facts.
those grants become available. 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 c05e060c..a570ff66 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -95,7 +95,10 @@ every commit beyond the repository's default branch, independently of the
PR's chosen base, has a valid signature from a current maintain or admin
collaborator. 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
+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: it never declares one and refuses an untrusted job,
+so such jobs wait for a runner that does. 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
@@ -110,7 +113,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 b988b536..54934a9c 100644
--- a/cloudflare/dev/github.ts
+++ b/cloudflare/dev/github.ts
@@ -561,7 +561,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> };
@@ -729,9 +739,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();
@@ -897,6 +910,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/quest/m0/kix/operator-setup.md b/quest/m0/kix/operator-setup.md
index 587f32e5..38595f82 100644
--- a/quest/m0/kix/operator-setup.md
+++ b/quest/m0/kix/operator-setup.md
@@ -34,6 +34,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 0ba6942e..add2dc96 100644
--- a/quest/m1/kix/README.md
+++ b/quest/m1/kix/README.md
@@ -16,7 +16,6 @@ maintainers, so they need none of this first.
## Required
- [Setup PR](/quest/m1/kix/setup-pr.md) - enrolling a repository without Quest makes the App open its `quest init` pull request
-- [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
- [Missing review flag](/quest/m1/kix/review-flag.md) - the inbox shows a PR's missing review before the merge click
diff --git a/quest/m1/kix/ci-read-only.md b/quest/m1/kix/ci-read-only.md
deleted file mode 100644
index 72c63192..00000000
--- a/quest/m1/kix/ci-read-only.md
+++ /dev/null
@@ -1,58 +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. 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
-- [Head approval](/quest/m0/kix/sandbox/head-approval.md) - the devShell stage's trust rule for heads without signatures
From 2f6e0163321e0cd76a7ba80f56139baf84d873cd Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Mon, 2 Nov 2026 19:55:50 -0800
Subject: [PATCH 4/8] docs(kix): format the trust table
Co-Authored-By: Claude Opus 5.5
---
cloudflare/GITHUB.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md
index fda047a7..0e338891 100644
--- a/cloudflare/GITHUB.md
+++ b/cloudflare/GITHUB.md
@@ -51,7 +51,7 @@ what kix may write.
| 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. A head kix pushed to is never trusted | The runner's host Nix evaluates the flake outside the sandbox, and "kix published it" proves only that a runner sent it ([head approval](../quest/m0/kix/sandbox/head-approval.md) plans an explicit per-head approval) |
| 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)) |
+| 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)) |
From 7bac40edb59929ccbd744167590413b1b86fed70 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Mon, 2 Nov 2026 20:03:48 -0800
Subject: [PATCH 5/8] fix(kix): look up protected branches beyond GitHub's
first page
Co-Authored-By: Claude Opus 5.5
---
cloudflare/src/github/ci.ts | 70 ++++++++++++++++++++++++--------
cloudflare/test/projects.test.ts | 57 +++++++++++++++++++++++---
2 files changed, 106 insertions(+), 21 deletions(-)
diff --git a/cloudflare/src/github/ci.ts b/cloudflare/src/github/ci.ts
index 01273ce8..43fedf32 100644
--- a/cloudflare/src/github/ci.ts
+++ b/cloudflare/src/github/ci.ts
@@ -113,41 +113,79 @@ export async function ciProblems(reads: CIReads, granted: ReadonlySet typeof value.total_count === "number" && Array.isArray(value.environments),
);
- // Read once, only when a policy names protected branches.
- let protectedBranches: Promise | null> | undefined;
- const branches = () =>
- (protectedBranches ??= read<{ name: string }[]>(
+ // 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 && new Set(list.map((branch) => branch.name))));
+ ).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 reads.get<{ protected?: unknown }>(
+ `/branches/${name.split("/").map(encodeURIComponent).join("/")}`,
+ );
+ if (typeof branch?.protected === "boolean") return branch.protected;
+ } catch (error) {
+ // 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.slice(0, ENVIRONMENTS) ?? []) {
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 branches();
- if (known && known.size === 0)
+ 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<{ branch_policies: Policy[] }>(
+ const policies = await read<{ total_count: number; branch_policies: Policy[] }>(
`${path}/deployment-branch-policies?per_page=100`,
`environment ${environment.name}'s deployment branches`,
- (value) => Array.isArray(value.branch_policies),
+ (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 patterns = policies.branch_policies.filter((p) => (p.type ?? "branch") === "branch");
- const fixed = patterns.map((p) => p.name.match(QUEUE)?.[1] ?? p.name);
- const known = fixed.some((name) => !WILDCARD.test(name)) ? await branches() : new Set();
- if (!known) continue;
- const open = patterns.filter((_, i) => WILDCARD.test(fixed[i]!) || !known.has(fixed[i]!));
- if (open.length)
- reason = `lets ${open.map((p) => p.name).join(", ")} deploy, which kix cannot confirm ${open.length === 1 ? "is a protected branch" : "are protected branches"}`;
+ 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);
+ // The failed read is already a warning.
+ 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(
diff --git a/cloudflare/test/projects.test.ts b/cloudflare/test/projects.test.ts
index 4861355e..dce61023 100644
--- a/cloudflare/test/projects.test.ts
+++ b/cloudflare/test/projects.test.ts
@@ -16,8 +16,10 @@ type CISettings = {
token?: "read" | "write";
repository?: string[];
organization?: string[];
- /** Branches GitHub reports protected; `trunk` by default. */
+ /** 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. */
@@ -180,9 +182,19 @@ function github(
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((ci.protected ?? ["trunk"]).map((name) => ({ name, protected: 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")
@@ -211,9 +223,13 @@ function github(
failed ??
Response.json({
total_count: found.policy.length,
- branch_policies: found.policy.map((name) =>
- name.startsWith("tag:") ? { name: name.slice(4), type: "tag" } : { name, type: "branch" },
- ),
+ branch_policies: found.policy
+ .slice(0, 100)
+ .map((name) =>
+ name.startsWith("tag:")
+ ? { name: name.slice(4), type: "tag" }
+ : { name, type: "branch" },
+ ),
})
);
}
@@ -396,6 +412,37 @@ describe("project enrollment", () => {
"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 }] },
From 445647fb24104329ad66750c583e6249d7b2ed1c Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Mon, 2 Nov 2026 20:06:19 -0800
Subject: [PATCH 6/8] fix(kix): bound the CI check by one read budget
Co-Authored-By: Claude Opus 5.5
---
cloudflare/GITHUB.md | 5 ++--
cloudflare/src/github/ci.ts | 40 ++++++++++++++++++-------
cloudflare/test/projects.test.ts | 50 +++++++++++++++++++++++++-------
3 files changed, 72 insertions(+), 23 deletions(-)
diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md
index 0e338891..c1e7dad3 100644
--- a/cloudflare/GITHUB.md
+++ b/cloudflare/GITHUB.md
@@ -322,8 +322,9 @@ 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 checks the first 12 environments, so the page
-stays within a Worker's subrequest budget, and says when there are more. It
+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.
diff --git a/cloudflare/src/github/ci.ts b/cloudflare/src/github/ci.ts
index 43fedf32..efc6cf81 100644
--- a/cloudflare/src/github/ci.ts
+++ b/cloudflare/src/github/ci.ts
@@ -18,10 +18,13 @@ export type CIPermission = keyof typeof CI_PERMISSIONS;
export type CIReads = { get(path: string): Promise };
/**
- * Environments examined per check. Each costs up to two reads, which keeps a
- * setup page within Workers Free's 50 subrequests alongside its other reads.
+ * 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 ENVIRONMENTS = 12;
+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 = {
@@ -55,12 +58,24 @@ function count(n: number, noun: string) {
*/
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 reads.get(path);
+ const value = await get(path);
if (value && valid(value)) return value;
- } catch {}
+ } catch (error) {
+ if (error === SPENT) return null;
+ }
problems.push(`kix could not read ${what}; review this project again later.`);
return null;
};
@@ -137,11 +152,12 @@ export async function ciProblems(reads: CIReads, granted: ReadonlySet {
try {
- const branch = await reads.get<{ protected?: unknown }>(
+ 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;
}
@@ -151,7 +167,11 @@ export async function ciProblems(reads: CIReads, granted: ReadonlySet ENVIRONMENTS)
+ if (listed && (stopped || listed.total_count > listed.environments.length))
problems.push(
- `kix checked only the first ${ENVIRONMENTS} of ${listed.total_count} environments. Limit the rest to protected branches too.`,
+ `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/test/projects.test.ts b/cloudflare/test/projects.test.ts
index dce61023..bef9106f 100644
--- a/cloudflare/test/projects.test.ts
+++ b/cloudflare/test/projects.test.ts
@@ -4,7 +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, ENVIRONMENTS } from "../src/github/ci";
+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";
@@ -463,20 +463,48 @@ describe("project enrollment", () => {
"kix could not read environments; review this project again later.",
]);
});
- it("checks a bounded number of environments within Workers Free's 50 external subrequests", async () => {
- const environments = Array.from({ length: ENVIRONMENTS + 3 }, (_, i) => ({
+ 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: ["feature/*"],
+ policy: null,
secrets: 1,
}));
- const { service, calls } = github({ ci: { environments }, subrequestLimit: 50 });
- await service.resolveRepository(repo.owner, repo.name);
+ const { service } = github({ ci: { environments } });
const { ci } = await service.previewProject(repo, "enrollment-test");
- expect(ci).toHaveLength(ENVIRONMENTS + 1);
- expect(ci.at(-1)).toBe(
- `kix checked only the first ${ENVIRONMENTS} of ${ENVIRONMENTS + 3} environments. Limit the rest to protected branches too.`,
- );
- expect(calls.length).toBeLessThanOrEqual(50);
+ 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");
From 5fa3ef1f0616b580bc5e0c01e5ecf64d8c425d75 Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Tue, 3 Nov 2026 08:54:35 -0800
Subject: [PATCH 7/8] docs(kix): say what an unverified VM declaration does to
an untrusted job
Co-Authored-By: Claude Opus 5.5
---
README.md | 2 +-
cloudflare/README.md | 7 +++++--
2 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 993fd9ff..bbe53b16 100644
--- a/README.md
+++ b/README.md
@@ -1159,7 +1159,7 @@ 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. `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 refuses one if sent. A pull request's head is trusted only when every
+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/cloudflare/README.md b/cloudflare/README.md
index 04c0bc2e..64b0819b 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -104,8 +104,11 @@ creation and again at assignment. Other heads, including unsigned ones and
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: it never declares one and refuses an untrusted job, so such jobs wait
-for a runner that does. A `merge` job runs the merge
+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
From 48f753373d05566b25fb8bde9afb7d12fb22e3aa Mon Sep 17 00:00:00 2001
From: Luke Curley
Date: Tue, 3 Nov 2026 09:30:02 -0800
Subject: [PATCH 8/8] fix(kix): show the CI check beside the setup pull
request's form
Merging the setup pull request enables contributions, so a repository
without Quest files now gets the same pull-request CI warnings before
the maintainer opens it. The check runs on the preview's token when the
preview finds no Quest files, and NoQuestFiles carries its warnings.
Co-Authored-By: Claude Opus 5.5
---
cloudflare/GITHUB.md | 3 +-
cloudflare/src/github/service.ts | 31 ++++++++++-----
cloudflare/src/index.tsx | 2 +-
cloudflare/src/projects.tsx | 67 +++++++++++++++++++-------------
cloudflare/test/projects.test.ts | 12 ++++++
cloudflare/test/setup.test.ts | 2 +
6 files changed, 79 insertions(+), 38 deletions(-)
diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md
index c87d4066..d566cdee 100644
--- a/cloudflare/GITHUB.md
+++ b/cloudflare/GITHUB.md
@@ -305,7 +305,8 @@ 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, checks the repository's settings with the installation's token
+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;
diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts
index a546d9ec..ecd79fa7 100644
--- a/cloudflare/src/github/service.ts
+++ b/cloudflare/src/github/service.ts
@@ -112,7 +112,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.");
}
}
@@ -1248,19 +1249,31 @@ export class GitHubService {
return commits.size === count && (count === 0 || commits.has(revision));
}
- /** The setup page's preview, with the pull-request CI check (`./ci.ts`) on the same token. */
+ /**
+ * 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) => ({
- ...(await this.#preview(token, repo, current, installation)),
- ci: await ciProblems(
- { get: (path) => this.#api(token, `${repositoryPath(repo)}${path}`) },
- new Set((Object.keys(CI_PERMISSIONS) as CIPermission[]).filter((p) => scope[p])),
- ),
- }),
+ 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 18b80f34..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,32 +404,7 @@ export function ProjectSetupPage(props: { preview: ProjectPreview; enrollment: E
>
)}
-
- 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.
-
- {preview.ci.length ? (
-
- {preview.ci.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.
-
-
+
Ready quests
{props.enrollment && (
diff --git a/cloudflare/test/projects.test.ts b/cloudflare/test/projects.test.ts
index 54e27c22..c771a3e3 100644
--- a/cloudflare/test/projects.test.ts
+++ b/cloudflare/test/projects.test.ts
@@ -522,6 +522,18 @@ describe("project enrollment", () => {
// 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);