Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

231 lines · 12.5 KBRaw History
1import { ZERO_OID, diffTrees, mergeBase, peelToCommit, readCommit, resolvePath } from "@diffinery/git-core";
2import { HttpError, now } from "../lib/util.js";
3import type { RepoDO } from "./RepoDO.js";
4import type { Caller } from "./types.js";
5
6/** How far back from a ref's tip to look for (or record) agents' commits. */
7const AGENT_WALK = 5000;
8
9/**
10 * What a workflow's GITHUB_TOKEN is trusted with, beyond its `permissions:`
11 * (lib/job-permissions.ts):
12 *
13 * - The repository's Actions settings, as on GitHub: the default token
14 * permissions (read-only for repositories created from now on, read and write
15 * for older ones) and whether workflows may create and approve pull requests.
16 * - Whether a run's code came from an agent: the agent pushed it, opened the
17 * pull request, or pushed the branch last. Such runs act as an agent, so the
18 * agent guardrails (no pushes or merges to protected and default branches,
19 * advisory reviews) hold for code an agent wrote, whoever triggered the run.
20 * - Workflow files: a job token never creates or changes them (GitHub's
21 * `workflows` permission, which GITHUB_TOKEN cannot have).
22 * - Secrets in runs of agent code: none from the repository or organization
23 * unless an admin allows it (`agentRunSecrets`), since an agent with write
24 * access could otherwise push a workflow that sends them anywhere.
25 */
26
27export type WorkflowSettings = { defaultPermissions: "read" | "write"; canApprovePullRequests: boolean; agentRunSecrets: boolean };
28
29const SETTINGS_KEY = "actions_workflow_settings";
30/** Repositories that predate the setting keep GitHub's old default. */
31const LEGACY: WorkflowSettings = { defaultPermissions: "write", canApprovePullRequests: false, agentRunSecrets: false };
32const NEW_REPO: WorkflowSettings = { defaultPermissions: "read", canApprovePullRequests: false, agentRunSecrets: false };
33
34export const WORKFLOW_DIRS = [".github/workflows", ".diffinery/workflows"];
35
36export const isJobCaller = (c: { id: string }) => c.id.startsWith("job:");
37/** An app's installation token (apps/, auth/principal.ts): `app:<installation id>`. */
38export const isAppCaller = (c: { id: string }) => c.id.startsWith("app:");
39
40export class TokenPolicy {
41 constructor(private repo: RepoDO) {}
42
43 private get sql() {
44 return this.repo.sql;
45 }
46
47 // ---------------------------------------------------------------- settings
48
49 /** Called when a repository is created: it starts with GitHub's current (read-only) default. */
50 initNewRepo(): void {
51 this.sql.exec("INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)", SETTINGS_KEY, JSON.stringify(NEW_REPO));
52 }
53
54 settings(): WorkflowSettings {
55 const row = this.sql.exec<{ value: string }>("SELECT value FROM meta WHERE key = ?", SETTINGS_KEY).toArray()[0];
56 return row ? { ...LEGACY, ...(JSON.parse(row.value) as Partial<WorkflowSettings>) } : { ...LEGACY };
57 }
58
59 /** Changes the settings and audits the change; `via: "api"` marks one made through the REST API. */
60 setSettings(patch: Partial<WorkflowSettings>, caller: Caller, o: { via?: "api" } = {}): WorkflowSettings {
61 if (caller.role !== "admin") throw new HttpError(403, "admin access required");
62 if (caller.kind !== "user") throw new HttpError(403, "only people can change what workflows are allowed to do");
63 if (patch.defaultPermissions !== undefined && patch.defaultPermissions !== "read" && patch.defaultPermissions !== "write") throw new HttpError(422, "default_workflow_permissions must be read or write");
64 if (patch.canApprovePullRequests !== undefined && typeof patch.canApprovePullRequests !== "boolean") throw new HttpError(422, "can_approve_pull_request_reviews must be a boolean");
65 if (patch.agentRunSecrets !== undefined && typeof patch.agentRunSecrets !== "boolean") throw new HttpError(422, "agent_runs_use_secrets must be a boolean");
66 const next = { ...this.settings(), ...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)) } as WorkflowSettings;
67 this.sql.exec("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", SETTINGS_KEY, JSON.stringify(next));
68 const m = this.repo.meta;
69 this.repo.emit({ type: "audit", action: "repo.actions_workflow_settings", actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, target: `${m.owner}/${m.name}`, detail: { ...next, ...(o.via ? { via: o.via } : {}) }, at: now() });
70 return next;
71 }
72
73 /** Workflows creating or approving pull requests is a setting, off by default (GitHub's message). */
74 assertMayCreateOrApprove(caller: Caller, what: "create" | "approve"): void {
75 if (!isJobCaller(caller) || this.settings().canApprovePullRequests) return;
76 throw new HttpError(403, `GitHub Actions is not permitted to ${what === "create" ? "create" : "approve"} pull requests. An admin can allow it in the repository's Actions settings ("Allow GitHub Actions to create and approve pull requests").`);
77 }
78
79 // ---------------------------------------------------------------- where code came from
80
81 /** Records who moved `ref` (pushes, merges, API commits). */
82 recordPush(ref: string, newOid: string, caller: Caller): void {
83 if (newOid === ZERO_OID) this.sql.exec("DELETE FROM ref_actors WHERE ref = ?", ref);
84 else this.sql.exec("INSERT OR REPLACE INTO ref_actors (ref, actor_id, actor_kind, updated_at) VALUES (?, ?, ?, ?)", ref, caller.id, caller.kind, now());
85 }
86
87 /**
88 * Keeps track of commits agents pushed that aren't on the default branch yet. An agent's push
89 * records the commits it brought (up to the default branch or the ref's old tip); the default
90 * branch moving forward drops the ones that landed, since its runs go by who moved it.
91 */
92 async trackAgentCommits(ref: string, oldOid: string, newOid: string, caller: Caller): Promise<void> {
93 if (newOid === ZERO_OID) return;
94 const isDefault = ref === this.repo.headRef;
95 if (!isDefault && caller.kind !== "agent") return;
96 if (isDefault && !this.sql.exec("SELECT 1 FROM agent_commits LIMIT 1").toArray().length) return;
97 const tip = await peelToCommit(this.repo.store, newOid).catch(() => null);
98 if (!tip) return;
99 const stop = [oldOid, isDefault ? null : await this.forkPoint(tip)].filter((o): o is string => !!o && o !== ZERO_OID);
100 const fresh = await this.commitsSince(tip, stop, AGENT_WALK);
101 const at = now();
102 for (const oid of fresh) {
103 if (isDefault) this.sql.exec("DELETE FROM agent_commits WHERE oid = ?", oid);
104 else this.sql.exec("INSERT OR IGNORE INTO agent_commits (oid, pushed_at) VALUES (?, ?)", oid, at);
105 }
106 this.agentCodeSeen.clear();
107 }
108
109 /** Where `tip` meets the default branch: the commits past it are the ref's own. */
110 private async forkPoint(tip: string): Promise<string | null> {
111 const head = this.repo.getRef(this.repo.headRef);
112 return head ? await mergeBase(this.repo.store, head, tip).catch(() => null) : null;
113 }
114
115 /** Commits reachable from `tip`, nearest first (by parents, not dates, which a pusher sets), short of `stop`. */
116 private async commitsSince(tip: string, stop: string[], limit: number): Promise<string[]> {
117 const seen = new Set(stop);
118 const out: string[] = [];
119 let level = [tip];
120 while (level.length && out.length < limit) {
121 const next: string[] = [];
122 for (const oid of level) {
123 if (seen.has(oid) || out.length >= limit) continue;
124 seen.add(oid);
125 const c = await readCommit(this.repo.store, oid).catch(() => null);
126 if (!c) continue; // shallow or missing parents
127 out.push(oid);
128 next.push(...c.parents);
129 }
130 level = next;
131 }
132 return out;
133 }
134
135 /** Whether the code at `sha` includes a commit an agent pushed that hasn't landed on the default branch. */
136 private async includesAgentCommit(sha: string): Promise<boolean> {
137 if (!this.sql.exec("SELECT 1 FROM agent_commits LIMIT 1").toArray().length) return false;
138 const cached = this.agentCodeSeen.get(sha);
139 if (cached !== undefined) return cached;
140 const tip = await peelToCommit(this.repo.store, sha).catch(() => null);
141 let found = false;
142 if (tip) {
143 const base = await this.forkPoint(tip);
144 for (const oid of await this.commitsSince(tip, base ? [base] : [], AGENT_WALK)) {
145 if (this.sql.exec("SELECT 1 FROM agent_commits WHERE oid = ?", oid).toArray().length) {
146 found = true;
147 break;
148 }
149 }
150 }
151 if (this.agentCodeSeen.size > 200) this.agentCodeSeen.clear();
152 this.agentCodeSeen.set(sha, found);
153 return found;
154 }
155
156 /** includesAgentCommit's answers, per commit, until the next agent push or default branch move. */
157 private agentCodeSeen = new Map<string, boolean>();
158
159 lastPusherKind(ref: string): string | null {
160 return this.sql.exec<{ actor_kind: string }>("SELECT actor_kind FROM ref_actors WHERE ref = ?", ref).toArray()[0]?.actor_kind ?? null;
161 }
162
163 /**
164 * Whether a new run's code came from an agent: the trigger was an agent, the run
165 * re-runs one that did (`inherited`), the branch or tag it runs was last moved by an
166 * agent, for pull request events an agent opened the pull request or last pushed its
167 * head branch, or, off the default branch, its code includes a commit an agent pushed
168 * (so a person's push, merge, release tag or dispatch on top doesn't make it theirs).
169 */
170 async agentOrigin(o: { caller: { kind: string }; ref: string; sha?: string; prNumbers?: number[]; inherited?: boolean }): Promise<boolean> {
171 if (o.inherited || o.caller.kind === "agent") return true;
172 if ((o.ref.startsWith("refs/heads/") || o.ref.startsWith("refs/tags/")) && this.lastPusherKind(o.ref) === "agent") return true;
173 for (const n of o.prNumbers ?? []) {
174 const pr = this.sql.exec<{ author_kind: string; head_ref: string }>("SELECT i.author_kind, p.head_ref FROM issues i JOIN pulls p ON p.number = i.number WHERE i.number = ?", n).toArray()[0];
175 if (!pr) continue;
176 if (pr.author_kind === "agent" || this.lastPusherKind(`refs/heads/${pr.head_ref}`) === "agent") return true;
177 }
178 // The default branch's own runs go by who moved it: what a person merged there is theirs.
179 if (o.sha && o.ref !== this.repo.headRef && (await this.includesAgentCommit(o.sha))) return true;
180 return false;
181 }
182
183 /** A pull request whose head is in another repository (a fork), from the pull request's own row. */
184 isForkPull(number: number | null): boolean {
185 if (number === null) return false;
186 const r = this.sql.exec<{ head_repo_id: string | null; head_ref: string }>("SELECT head_repo_id, head_ref FROM pulls WHERE number = ?", number).toArray()[0];
187 if (!r) return false;
188 // Imported pull requests from GitHub forks keep their head as "owner:branch", never a local branch.
189 return (!!r.head_repo_id && r.head_repo_id !== this.repo.meta.repoId) || r.head_ref.includes(":");
190 }
191
192 // ---------------------------------------------------------------- workflow files
193
194 private async workflowDir(commit: string | null, dir: string): Promise<string | null> {
195 if (!commit) return null;
196 const c = await readCommit(this.repo.store, await peelToCommit(this.repo.store, commit));
197 const e = await resolvePath(this.repo.store, c.tree, dir);
198 return e?.oid ?? null;
199 }
200
201 /**
202 * The first workflow file that moving a ref from `oldOid` to `newOid` creates,
203 * changes or deletes. A new ref is compared with where it branched off the
204 * default branch.
205 */
206 async changedWorkflow(oldOid: string, newOid: string): Promise<string | null> {
207 if (newOid === ZERO_OID) return null;
208 let base: string | null = oldOid === ZERO_OID ? null : oldOid;
209 if (!base) {
210 const head = this.repo.getRef(this.repo.headRef);
211 base = head ? await mergeBase(this.repo.store, head, await peelToCommit(this.repo.store, newOid)) : null;
212 }
213 for (const dir of WORKFLOW_DIRS) {
214 const [a, b] = await Promise.all([this.workflowDir(base, dir), this.workflowDir(newOid, dir)]);
215 if (a === b) continue;
216 const changes = await diffTrees(this.repo.store, a, b, dir).catch(() => []);
217 return changes[0]?.path ?? dir;
218 }
219 return null;
220 }
221
222 /**
223 * GitHub's refusal for a job token, or an app's installation token, that would add or change a
224 * workflow file. Apps are never offered the `workflows` permission, so they never can.
225 */
226 async refuseWorkflowEdit(caller: Caller, oldOid: string, newOid: string): Promise<string | null> {
227 if (!isJobCaller(caller) && !isAppCaller(caller)) return null;
228 const path = await this.changedWorkflow(oldOid, newOid);
229 return path ? `refusing to allow a GitHub App to create or update workflow \`${path}\` without \`workflows\` permission` : null;
230 }
231}