// What the forge's tools refuse (novox/hq ADR 0266): whoever may call a tool includes agents, and the token // the tools hold is minted for the forge's administrator. Through `gitea_api` any endpoint was one call away: // an agent could drop a branch's protection or its required checks, write to main, set the merge check's // status, or merge its own pull request — and a module merged into the catalogue is what the next push sends, // as root, to every node. So: // // - `gitea_api` reads anything but the forge's administration, and writes only what an author writes: // issues, comments, labels, pull requests (opened, edited, reviewed — never merged), branches, and a // file on a branch that is neither the repository's default branch nor one a protection rule names. // Everything else — administration, protection, settings, collaborators, teams, hooks, keys, tokens, // statuses, a merge, a repository deleted or moved — has its own tool or is the operator's, at the // forge as themselves; // - `gitea_branch_protection_set` only makes protection stronger; // - `gitea_merge_pull_request` merges only a head whose every required status succeeded, and only that // head; // - `gitea_delete_repo` refuses a repository any protection rule guards. // // Pure, so it is tested without a forge; the tools do the asking (tools/index.ts). import type { BranchProtection, ProtectionWanted } from "./protection.js"; /** A refusal of policy, in the words the caller sees. Never an error of the forge's. */ export class Refused extends Error { constructor(message: string) { super(message); this.name = "Refused"; } } /** What `gitea_api` may do with one call, as far as the call alone says. */ export type ApiJudgement = | { allowed: true } | { refused: string } /** A file written: allowed only on a branch that is not the default branch and that no rule names. */ | { contents: { owner: string; repo: string; branch?: string; newBranch?: string } }; const READS = new Set(["GET", "HEAD"]); const seg = "([^/]+)"; const repoPath = `^/repos/${seg}/${seg}`; /** The writes an author makes, by method and path. Anything not here is refused. */ const AUTHOR_WRITES: ReadonlyArray<{ methods: string[]; path: RegExp }> = [ { methods: ["POST"], path: new RegExp(`${repoPath}/issues$`) }, { methods: ["PATCH"], path: new RegExp(`${repoPath}/issues/\\d+$`) }, { methods: ["POST"], path: new RegExp(`${repoPath}/issues/\\d+/comments$`) }, { methods: ["PATCH", "DELETE"], path: new RegExp(`${repoPath}/issues/comments/\\d+$`) }, { methods: ["POST", "PUT", "DELETE"], path: new RegExp(`${repoPath}/issues/\\d+/labels(/\\d+)?$`) }, { methods: ["POST"], path: new RegExp(`${repoPath}/labels$`) }, { methods: ["PATCH", "DELETE"], path: new RegExp(`${repoPath}/labels/\\d+$`) }, { methods: ["POST"], path: new RegExp(`${repoPath}/pulls$`) }, { methods: ["PATCH"], path: new RegExp(`${repoPath}/pulls/\\d+$`) }, { methods: ["POST"], path: new RegExp(`${repoPath}/pulls/\\d+/reviews(/\\d+(/comments)?)?$`) }, { methods: ["POST", "DELETE"], path: new RegExp(`${repoPath}/pulls/\\d+/requested_reviewers$`) }, { methods: ["POST"], path: new RegExp(`${repoPath}/branches$`) }, { methods: ["POST"], path: /^\/user\/repos$/ }, { methods: ["POST"], path: /^\/markdown(\/raw)?$/ }, ]; /** A file written through the contents API: one file, or several in one commit. */ const CONTENTS = new RegExp(`${repoPath}/contents(/.*)?$`); /** Reads that are no one's but the administrator's. */ const ADMIN_READS = [/^\/admin(\/|$)/, /^\/users\/[^/]+\/tokens(\/|$)/]; /** * The path as the forge would route it: relative to /api/v1, its query dropped, refused when it could be read * as another path — a dot segment, an encoded slash or dot, a backslash, an empty segment. A path the forge and * this judge could read differently is never judged. */ export function routedPath(raw: string): string { let path = String(raw ?? "").trim(); if (!path.startsWith("/")) path = "/" + path; path = path.replace(/^\/api\/v1(?=\/|$)/, "") || "/"; const q = path.search(/[?#]/); if (q >= 0) path = path.slice(0, q); if (/%2e|%2f|%5c|\\/i.test(path)) throw new Refused(`${raw}: an encoded dot, slash or backslash is not taken`); path = path.replace(/\/+$/, "") || "/"; const segments = path.split("/").slice(1); if (path !== "/" && segments.some((s) => s === "" || s === "." || s === "..")) { throw new Refused(`${raw}: a path with an empty or dot segment is not taken`); } return path; } /** What `gitea_api` may do with this call, judged on the call alone (novox/hq ADR 0266). */ export function judgeApiCall(method: string, rawPath: string, body: unknown): ApiJudgement { const m = String(method || "GET").toUpperCase(); let path: string; try { path = routedPath(rawPath); } catch (err) { return { refused: (err as Error).message }; } if (READS.has(m)) { if (ADMIN_READS.some((r) => r.test(path))) { return { refused: `${m} ${path} is the forge's administration, which no tool reads (novox/hq ADR 0266)` }; } return { allowed: true }; } const contents = path.match(CONTENTS); if (contents && ["POST", "PUT", "DELETE"].includes(m)) { const b = (body ?? {}) as Record; const str = (v: unknown) => (typeof v === "string" && v.trim() !== "" ? v.trim() : undefined); return { contents: { owner: contents[1], repo: contents[2], branch: str(b.branch), newBranch: str(b.new_branch) } }; } if (AUTHOR_WRITES.some((w) => w.methods.includes(m) && w.path.test(path))) return { allowed: true }; return { refused: `${m} ${path} is not a write gitea_api makes (novox/hq ADR 0266): it writes only what an author ` + `writes — issues, comments, labels, pull requests (never their merge), branches, and files on a branch ` + `that is not the default branch and that no protection rule names. The forge's administration, a ` + `branch's protection, a repository's settings, collaborators, teams, hooks, keys, tokens and statuses ` + `are the operator's at the forge, or have their own tool (gitea_merge_pull_request, gitea_commit_status, ` + `gitea_branch_protection_set, which only strengthens). Nothing was sent`, }; } /** Whether a protection rule's name — a branch, or a glob of them (`*`, `**`) — covers a branch. */ export function ruleCovers(rule: string, branch: string): boolean { if (rule === branch) return true; if (!/[*?[]/.test(rule)) return false; let re = ""; for (let i = 0; i < rule.length; i++) { const c = rule[i]; if (c === "*" && rule[i + 1] === "*") { re += ".*"; i++; } else if (c === "*") re += "[^/]*"; else if (c === "?") re += "[^/]"; else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&"); } return new RegExp(`^${re}$`).test(branch); } /** Refuses a file written to the default branch or to a branch a protection rule names. */ export function judgeContentsWrite(target: string, defaultBranch: string, rules: BranchProtection[]): string | null { if (target === defaultBranch) { return `a file written to ${target}, the repository's default branch, is refused: a change reaches it by a pull ` + `request that passes its checks (novox/hq ADR 0266). Write it on a branch of its own (new_branch). Nothing was sent`; } const guarding = rules.map((r) => String(r.rule_name ?? r.branch_name ?? "")).filter((r) => r && ruleCovers(r, target)); if (guarding.length) { return `a file written to ${target} is refused: the protection rule ${guarding.join(", ")} guards it ` + `(novox/hq ADR 0266). Write it on a branch of its own. Nothing was sent`; } return null; } /** Refuses a protection change that weakens the rule a branch has (novox/hq ADR 0266). */ export function judgeProtectionChange(before: BranchProtection | null, want: ProtectionWanted): string | null { if (!before) return null; // A new rule protects what nothing protected. const had = before.enable_status_check ? [...new Set((before.status_check_contexts ?? []).filter(Boolean))] : []; const wanted = new Set(want.statusChecks.map((c) => c.trim()).filter(Boolean)); const dropped = had.filter((c) => !wanted.has(c)); const why: string[] = []; if (dropped.length) why.push(`it drops the required status ${dropped.join(", ")}`); if (want.push === true && before.enable_push === false) why.push("it lets a person push to the branch directly"); if (want.blockAdminOverride === false && before.block_admin_merge_override === true) { why.push("it lets an administrator merge past a status that has not succeeded"); } if (!why.length) return null; return `the protection of ${before.rule_name ?? before.branch_name} is only made stronger through a tool: ` + `${why.join("; ")}. Whoever may call a tool includes agents, and a branch's protection is what keeps a ` + `change from merging without its checks and the operator (novox/hq ADR 0266) — weakening it is the ` + `operator's, at the forge as themselves. Nothing was changed`; } /** The merge methods the forge takes for a merge that is a merge — never `manually-merged`. */ export const MERGE_METHODS = new Set(["merge", "rebase", "rebase-merge", "squash", "fast-forward-only"]); /** A repository owner's or name's shape: what keeps a name from becoming another path. */ export function checkName(what: string, value: unknown): string { const v = String(value ?? "").trim(); if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(v) || v === "." || v === "..") { throw new Refused(`${JSON.stringify(value)} is not a repository ${what}`); } return v; }