| 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 | |
| 38 | export 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 | } |