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.

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