Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,12 @@ token; a new plan reads your idea and pasted context there. kix publishes as its
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, and a job resumes with another maintainer's answer
heads' devShells in a VM. 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))
for its iterate and merge jobs to run; a head kix has pushed to stays
untrusted. A job resumes with another maintainer's answer
only once you approve it in the inbox. opencode runs as `opencode serve` on
localhost behind a password made for each turn.

Expand Down
8 changes: 6 additions & 2 deletions cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,12 @@ target; a retry keeps both. A completed job's result may list suggested
idea and takes the follow-ups and summary as context from the job's own
result, never from the request, and offers them once. A job on an issue or pull request
rereads it from GitHub, refusing one that closed or whose head moved since
observations read it. `iterate` and `merge` jobs are untrusted and route
only to Linux runners that declare VM builds. A `merge` job runs the merge
observations read it. `iterate` and `merge` jobs trust a head only when
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
skill's loop on an open, non-draft pull request and lands it; it holds the PR
with the `quest:merge` label that local `quest merge` sessions share
(`src/jobs/landing.ts`), as described in [GITHUB.md](GITHUB.md#merge-jobs). A five-minute scheduled sweep retries ended jobs’ hold releases even when a lifecycle callback or GitHub read was missed; a live job, a replacement hold, or an active landing keeps its label. Without a `flake.nix`, the route
Expand Down
69 changes: 67 additions & 2 deletions cloudflare/dev/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export type Issue = {
};
export type Thread = { path: string; line: number | null; resolved?: boolean; outdated?: boolean; comments: Comment[] };
export type Pull = Issue & {
/** The PR base; retargeting changes its commit list, never the trusted default branch. */
base?: string;
/** Head branch; `quest/<path>` associates the pull request with that quest. */
branch: string;
checks: "SUCCESS" | "FAILURE" | "PENDING" | null;
Expand Down Expand Up @@ -51,7 +53,13 @@ export type Pull = Issue & {
/** A fork's head branch accepts pushes from the upstream's maintainers. */
maintainerCanModify?: boolean;
};
export type Commit = { parents: string[]; tree: string; message: string };
export type Commit = {
parents: string[];
tree: string;
message: string;
/** GitHub's authenticated signature result, independent of author identity. */
signature?: { isValid: boolean; signer: { databaseId: number; login: string } | null } | null;
};
/** A tree written through the API: its base tree and the entries changed on it. */
export type Tree = { base: string; entries: unknown[] };
export type Repo = {
Expand All @@ -60,6 +68,8 @@ export type Repo = {
name: string;
/** Logins with the maintain role; everyone else can only read. */
maintainers: string[];
/** Defaults to main. */
defaultBranch?: string;
/** The default branch's files. */
files: { path: string; content: string }[];
issues: Issue[];
Expand Down Expand Up @@ -327,6 +337,61 @@ function graphql(repo: Repo, query: string, variables: Record<string, unknown>,
);
return { data: { repository: objects } };
}
if (query.includes("signature {")) {
const pull = repo.pulls.find((p) => p.number === variables.number);
if (!pull) return { data: { repository: { pullRequest: null } } };
const branch = repo.defaultBranch ?? "main";
const anchor = repo.branches[branch] ?? commitOf(repo);
const comparison = query.includes("compare(headRef:");
const base = comparison ? anchor : (repo.branches[pull.base ?? branch] ?? anchor);
const head = comparison ? String(variables.revision) : headOf(repo, pull);
const ancestors = (tip: string, seen = new Set<string>()): Set<string> => {
if (seen.has(tip)) return seen;
seen.add(tip);
for (const parent of repo.commits[tip]?.parents ?? []) ancestors(parent, seen);
return seen;
};
const inherited = ancestors(base);
const commits = [...ancestors(head)].filter((oid) => !inherited.has(oid));
const start = variables.after ? Number(variables.after) : 0;
const end = Math.min(start + 100, commits.length);
const nodes = commits
.slice(start, end)
.map((oid) => ({ oid, signature: repo.commits[oid]?.signature ?? null }));
const connection = {
totalCount: commits.length,
nodes,
pageInfo: { hasNextPage: end < commits.length, endCursor: String(end) },
};
const live = { state: pull.closed ? "CLOSED" : "OPEN", headRefOid: headOf(repo, pull) };
return {
data: {
repository: comparison
? {
pullRequest: live,
defaultBranchRef: {
name: branch,
target: { oid: anchor },
compare: repo.commits[head]
? {
baseTarget: { oid: base },
headTarget: { oid: head },
commits: connection,
}
: null,
},
}
: {
pullRequest: {
...live,
baseRefOid: base,
commits: { ...connection, nodes: nodes.map((commit) => ({ commit })) },
},
},
},
};
}

if (query.includes("timelineItems(")) {
const pull = repo.pulls.find((p) => p.number === variables.number);
return { data: { repository: { pullRequest: pull ? holdsTarget(repo, pull) : null } } };
Expand Down Expand Up @@ -692,7 +757,7 @@ export function fakeGitHub(world: World) {
owner: { login: repo.owner },
private: false,
visibility: "public",
default_branch: "main",
default_branch: repo.defaultBranch ?? "main",
archived: false,
disabled: false,
license: { spdx_id: "MIT" },
Expand Down
106 changes: 105 additions & 1 deletion cloudflare/src/github/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -812,7 +812,7 @@ export class GitHubService {
* open at the observed head, and whether its revision has a flake for the
* runner's devShell.
*/
jobSource<T extends { target: string; revision: string }>(
jobSource<T extends { target: string; revision: string; trusted: boolean }>(
repo: GitHubRepository,
userId: string,
resolve: (state: ProjectState) => T,
Expand All @@ -839,6 +839,7 @@ export class GitHubService {
if (live.state !== "open" || (kind === "pulls" && live.head?.sha !== job.revision))
throw new GitHubError("This changed on GitHub since it was read; refresh and review it again");
}
if (kind === "pulls") job.trusted = await this.#pullTrust(token, repo, Number(target), job.revision);
let flake = true;
try {
await this.#api(token, `${repositoryPath(repo)}/contents/flake.nix?ref=${sha(job.revision)}`);
Expand All @@ -850,6 +851,109 @@ export class GitHubService {
});
}

/** Recheck a queued PR head against current signatures and repository roles. */
jobTrust(repo: GitHubRepository, target: string, revision: string) {
return this.#withRepository(repo, { contents: "read", pull_requests: "read" }, async (token, current) => {
this.#public(current);
return this.#pullTrust(token, repo, pullNumber(target), revision);
});
}

async #pullTrust(token: string, repo: GitHubRepository, pull: number, revision: string) {
type Signer = { databaseId: number; login: string };
type Commit = { oid: string; signature: { isValid: boolean; signer: Signer | null } | null };
type Comparison = {
baseTarget: { oid: string };
headTarget: { oid: string };
commits: {
totalCount: number;
nodes: Commit[];
pageInfo: { hasNextPage: boolean; endCursor: string | null };
};
};
type Page = {
pullRequest: { state: string; headRefOid: string } | null;
defaultBranchRef: { name: string; target: { oid: string } | null; compare: Comparison | null } | null;
};
// A PR's base is contributor-controlled. Compare the exact head against the
// repository's trusted default branch so retargeting cannot hide ancestors.
const query = `query($owner:String!,$name:String!,$number:Int!,$revision:String!,$after:String) {
repository(owner:$owner,name:$name) {
pullRequest(number:$number) { state headRefOid }
defaultBranchRef { name target { oid } compare(headRef:$revision) {
Comment thread
kixelated marked this conversation as resolved.
Resolved
baseTarget { oid } headTarget { oid }
commits(first:100,after:$after) {
totalCount nodes { oid signature { isValid signer { databaseId login } } }
pageInfo { hasNextPage endCursor }
} } } } }`;
let after: string | null = null;
let base: string | undefined;
let branch: string | undefined;
let count: number | undefined;
const cursors = new Set<string>();
const commits = new Set<string>();
const maintainers = new Map<number, boolean>();
do {
const response: { data?: { repository?: Page }; errors?: unknown[] } = await this.#api(
token,
"/graphql",
"POST",
{
query,
variables: { owner: repo.owner, name: repo.name, number: pull, revision: sha(revision), after },
},
);
if (response.errors?.length) throw new GitHubError("Could not verify pull request commits");
const repository: Page | undefined = response.data?.repository;
const live = repository?.pullRequest;
const anchor = repository?.defaultBranchRef;
const comparison = anchor?.compare;
if (!live || live.state !== "OPEN" || live.headRefOid !== revision || !anchor?.target || !comparison)
return false;
base ??= sha(anchor.target.oid);
branch ??= anchor.name;
const page = comparison.commits;
count ??= page.totalCount;
if (
base !== anchor.target.oid ||
branch !== anchor.name ||
count !== page.totalCount ||
comparison.baseTarget.oid !== base ||
comparison.headTarget.oid !== revision
)
return false;
for (const commit of page.nodes) {
if (commits.has(commit.oid)) throw new GitHubError("Repeated pull request commit");
commits.add(sha(commit.oid));
// Only a valid signature counts, never author email. kix's own commits
// are signed as the App, which is no maintainer, so they stay untrusted.
const signer = commit.signature?.isValid && commit.signature.signer;
if (!signer || !positiveId(signer.databaseId) || !name(signer.login)) return false;
if (!maintainers.has(signer.databaseId)) {
const permission = await this.#api<{ user: { id: number }; permission: string; role_name: string }>(
token,
`${repositoryPath(repo)}/collaborators/${signer.login}/permission`,
).catch((error: unknown) => {
if (error instanceof GitHubError && error.status === 404) return null;
throw error;
});
maintainers.set(
signer.databaseId,
!!permission &&
permission.user.id === signer.databaseId &&
(permission.permission === "admin" || permission.role_name === "maintain"),
);
}
if (!maintainers.get(signer.databaseId)) return false;
}
if (!page.pageInfo.hasNextPage) break;
after = page.pageInfo.endCursor;
if (!after || cursors.has(after)) throw new GitHubError("Missing or repeated pull request commit cursor");
cursors.add(after);
} while (true);
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),
Expand Down
9 changes: 8 additions & 1 deletion cloudflare/src/jobs/hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as landing from "./landing";
import * as output from "./output";
import { MessageError, type RunnerMessage, parse } from "./protocol";
import * as store from "./store";
import * as trust from "./trust";
import { JobError } from "./store";
import { instructions } from "./trail";

Expand Down Expand Up @@ -183,7 +184,13 @@ export class RunnerHub extends DurableObject<Env> {
const reported = (ws.deserializeAttachment() as RunnerAttachment).slots;
if (reported !== null) slots.set(runner, reported);
}
const { assign, cancel, ended, wake } = await store.dispatch(this.env.DB, owner, slots, Date.now());
const { assign, cancel, ended, wake } = await store.dispatch(
this.env.DB,
owner,
slots,
Date.now(),
trust.verify(this.env),
);
for (const { runner, job } of assign) {
const message = { type: "assign", job, instructions: instructions(job.workflow) };
connections.get(runner)?.send(JSON.stringify(message));
Expand Down
3 changes: 1 addition & 2 deletions cloudflare/src/jobs/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,8 +313,7 @@ function resolve(
const data = pull.data as Pull;
if (workflow === "merge" && data.isDraft)
throw new JobError("Mark the draft ready for review before merging it");
// Until the sandbox classifies commit authors, every PR head is untrusted
// and routes only to Linux runners that build its devShell in a VM.
// jobSource verifies every commit's provenance before creating this job.
return {
workflow,
target: `pulls/${pull.number}`,
Expand Down
Loading
Loading