| 1 | import { ZERO_OID, diffTrees, mergeBase, peelToCommit, readCommit, resolvePath } from "@diffinery/git-core"; |
| 2 | import { HttpError, now } from "../lib/util.js"; |
| 3 | import type { RepoDO } from "./RepoDO.js"; |
| 4 | import type { Caller } from "./types.js"; |
| 5 | |
| 6 | /** How far back from a ref's tip to look for (or record) agents' commits. */ |
| 7 | const 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 | |
| 27 | export type WorkflowSettings = { defaultPermissions: "read" | "write"; canApprovePullRequests: boolean; agentRunSecrets: boolean }; |
| 28 | |
| 29 | const SETTINGS_KEY = "actions_workflow_settings"; |
| 30 | /** Repositories that predate the setting keep GitHub's old default. */ |
| 31 | const LEGACY: WorkflowSettings = { defaultPermissions: "write", canApprovePullRequests: false, agentRunSecrets: false }; |
| 32 | const NEW_REPO: WorkflowSettings = { defaultPermissions: "read", canApprovePullRequests: false, agentRunSecrets: false }; |
| 33 | |
| 34 | export const WORKFLOW_DIRS = [".github/workflows", ".diffinery/workflows"]; |
| 35 | |
| 36 | export const isJobCaller = (c: { id: string }) => c.id.startsWith("job:"); |
| 37 | /** An app's installation token (apps/, auth/principal.ts): `app:<installation id>`. */ |
| 38 | export const isAppCaller = (c: { id: string }) => c.id.startsWith("app:"); |
| 39 | |
| 40 | export 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 | } |