// 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; } /** * The query keys `gitea_api` sends: what the forge's listing and reading endpoints take. Any other key is refused, * `sudo` above all — the forge lets a site administrator's token act as any user named by it, and the tools' token is * one (the confirmation review of 2026-10-09 read the operator's own account through `GET /user?sudo=…`). The token * keys are refused too: authentication is the client's, never the caller's. */ export const QUERY_KEYS: ReadonlySet = new Set([ "limit", "page", "state", "type", "q", "sort", "order", "since", "before", "ref", "sha", "path", "labels", "milestones", "keyword", "stat", "verification", "files", "not", "mode", "include_desc", "archived", "private", "exclusive", "uid", "priority_repo_id", "created_by", "assigned_by", "mentioned_by", "whitespace", "binary", ]); /** The query a call sends: only allow-listed keys, each as given; refused when any other key is there. */ export function sentQuery(raw: string): string { const at = String(raw ?? "").indexOf("?"); if (at < 0) return ""; const query = String(raw).slice(at + 1).split("#")[0]; const params = new URLSearchParams(query); const kept = new URLSearchParams(); for (const [key, value] of params) { if (key.toLowerCase() === "sudo") { throw new Refused(`the query key ${key} is refused: it would act as another user of the forge (novox/hq ADR 0266). Nothing was sent`); } if (!QUERY_KEYS.has(key)) { throw new Refused(`the query key ${key} is not one gitea_api sends: it sends ${[...QUERY_KEYS].join(", ")} (novox/hq ADR 0266). Nothing was sent`); } kept.append(key, value); } const out = kept.toString(); return out ? "?" + out : ""; } /** 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); sentQuery(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) } }; } // A pull request's base changed through the escape hatch would merge what was checked against one branch into // another; gitea_update_pull_request changes a title and a body, never a base. if (m === "PATCH" && /^\/repos\/[^/]+\/[^/]+\/pulls\/\d+$/.test(path) && body && typeof body === "object" && "base" in (body as Record)) { return { refused: `${m} ${path} changing a pull request's base is refused (novox/hq ADR 0266): its checks were judged against the base it had. Open a new pull request. Nothing was sent` }; } // An approval, or a request for changes, is a reviewer's word on a merge: the operator's, never a tool's — the // tools' token is the forge's administrator, and its approval would count as anyone's (novox/hq ADR 0266). if (m === "POST" && /^\/repos\/[^/]+\/[^/]+\/pulls\/\d+\/reviews(\/\d+)?$/.test(path)) { const event = String((body as Record | null)?.event ?? "").trim().toUpperCase(); if (event !== "" && event !== "COMMENT" && event !== "PENDING") { return { refused: `${m} ${path} with the event ${event} is refused: approving a pull request, or requesting ` + `changes on it, is the operator's at the forge as themselves, never a tool's (novox/hq ADR 0266). A review ` + `that only comments is sent. Nothing was sent` }; } } 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 covers a branch, as the forge itself decides it (Gitea 1.27, * models/git/protected_branch.go Match and modules/glob; novox/hq issue 539): a name without a glob character * covers the branch of that name in any case; any other name is a glob with `/` as its separator — `*` any run * within a segment, `**` any run across them, `?` one character of a segment, `[abc]`, `[a-z]` and `[!abc]` one * character, `{a,b}` either, `\` the next character itself — matched case-sensitively; a glob the forge cannot * compile covers its own text exactly. The forge bundle's ruleCovers (cmd/forge/rules.go) is the same rule, and * both are held to one table of answers (test/rule-cases.json). * * The forge hands the compiled glob to Go's regexp, which reads some things JavaScript's reads otherwise: a * backslash before a letter or digit (`\d`, `\w`, `\pL`, `\b`, `\A` are classes or anchors there), a class * that opens with `]` (`[]a]` holds `]` and `a` there, an empty class here) or with `^` (a negation there), * and a POSIX class (`[[:digit:]]`). * A rule using any of them is taken to cover every branch: refusing a write the forge would take is safe, * taking one it guards is not. */ export function ruleCovers(rule: string, branch: string): boolean { if (!/[*?\\[\]{}]/.test(rule)) return rule.toLowerCase() === branch.toLowerCase(); if (goReadsOtherwise(rule)) return true; const re = globRegExp(rule); return re ? re.test(branch) : rule === branch; } /** Whether a glob holds what Go's regexp reads otherwise than JavaScript's (see ruleCovers): fail closed. */ function goReadsOtherwise(rule: string): boolean { return /\\[\p{L}\p{N}]/u.test(rule) || /\[!?[\]^]/.test(rule) || /\[[^\]]*\[:/.test(rule); } /** The forge's glob as a regular expression, compiled as its modules/glob compiles it with `/` as separator. */ function globRegExp(glob: string): RegExp | null { const g = Array.from(glob); let out = ""; let pos = 0; // A character as itself: escaped when it is syntax to a regular expression, and only then — the `u` flag // refuses any other escape, and `-` may be escaped only inside a class. const lit = (c: string) => c.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"); const litInClass = (c: string) => c.replace(/[\]\\[^-]/g, "\\$&"); const chars = (): boolean => { out += "["; if (g[pos] === "!") { pos++; out += "^"; } while (pos < g.length) { const c = g[pos++]; if (c === "]") { out += "]"; return true; } if (c === "\\") { if (pos >= g.length) return false; out += litInClass(g[pos++]); } else out += c === "[" || c === "^" ? "\\" + c : c; } return false; }; const compile = (sub: boolean): boolean => { while (pos < g.length) { const c = g[pos++]; if (sub && c === "}") { out += ")"; return true; } switch (c) { case "*": if (g[pos] === "*") { pos++; out += ".*"; } else out += "[^/]*"; break; case "?": out += "[^/]"; break; case "[": if (!chars()) return false; break; case "{": out += "("; if (!compile(true)) return false; break; case ",": out += sub ? "|" : ","; break; case "\\": if (pos >= g.length) return false; out += lit(g[pos++]); break; default: out += lit(c); } } // An alternative left open is a glob the forge cannot compile. return !sub; }; if (!compile(false)) return null; try { return new RegExp(`^${out}$`, "u"); } catch { return null; } } /** 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 what guards a branch (novox/hq ADR 0266): the branch's own rule, or — * when it has none — every glob rule that covers it, since a rule named for the branch takes the glob's place. * A rule made new takes the forge's defaults for what it does not say: pushes refused, an administrator's * override allowed. */ export function judgeProtectionChange(before: BranchProtection | null, want: ProtectionWanted, covering: BranchProtection[] = []): string | null { if (!before) { for (const glob of covering) { const weaker = judgeProtectionChange(glob, { ...want, push: want.push ?? false, blockAdminOverride: want.blockAdminOverride ?? false, }); if (weaker) return weaker; } 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 token's own user's repositories, untouched for this long, are the only ones a tool deletes. */ export const DELETABLE_AFTER_DAYS = 30; /** * Refuses deleting a repository (novox/hq ADR 0266): deleted and made again under its name, a repository the mesh * builds from would be one without its protection. A tool deletes only a repository of the token's own user — the * mesh builds from organisations' and people's repositories, never the tools' own — that no protection rule * guards, with no commit for DELETABLE_AFTER_DAYS days, and only when confirm names it in full. */ export function judgeDelete(fullName: string, confirm: unknown, owner: string, self: string, rules: BranchProtection[], lastCommit: Date | null, now: Date): string | null { if (String(confirm ?? "") !== fullName) { return `confirm must be the repository's full name, ${fullName}, to delete it. Nothing was deleted`; } if (owner.toLowerCase() !== self.toLowerCase()) { return `${fullName} is not a repository of the tools' own user (${self}): a tool deletes only those, never an ` + `organisation's or a person's — the mesh builds from them (novox/hq ADR 0266). Deleting it is the operator's, at ` + `the forge as themselves. Nothing was deleted`; } if (rules.length) { return `${fullName} is guarded by the protection rule ${rules.map((r) => r.rule_name ?? r.branch_name).join(", ")}: ` + `deleting it is the operator's, at the forge as themselves (novox/hq ADR 0266). Nothing was deleted`; } if (lastCommit && now.getTime() - lastCommit.getTime() < DELETABLE_AFTER_DAYS * 24 * 3600 * 1000) { return `${fullName} had a commit on ${lastCommit.toISOString().slice(0, 10)}, within ${DELETABLE_AFTER_DAYS} days: ` + `a repository in use is not deleted by a tool (novox/hq ADR 0266). Nothing was deleted`; } return null; } /** 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; } /** * One caller-supplied path segment, encoded: refused when empty, `.` or `..`, or holding `/`, `?`, `#` or `%` — any * of which, encoded or not, could make the request another path or carry another query (the confirmation review of * 2026-10-09: a branch named `..` became DELETE /repos/o/r/, the repository itself). Every value a caller gives that * lands in a path goes through this, or through pathSegments. */ export function pathSegment(what: string, value: unknown): string { const v = String(value ?? ""); if (v === "" || v === "." || v === ".." || /[/?#%\\]/.test(v) || /[\u0000-\u001f]/.test(v)) { throw new Refused(`${JSON.stringify(value)} is not a ${what} this tool puts in a path. Nothing was sent`); } return encodeURIComponent(v); } /** A caller-supplied value that is several segments — a branch like feat/x, a file's path — each judged as one. */ export function pathSegments(what: string, value: unknown): string { return String(value ?? "").split("/").map((part) => pathSegment(what, part)).join("/"); } /** * Every path the client sends, judged once more where it is sent: no empty, `.` or `..` segment, encoded or not, and * only the query keys the tools send (sentQuery). What a caller's text could still make of a path built elsewhere * stops here, before the forge. */ export function checkSentPath(path: string): void { const at = path.search(/[?#]/); const route = at < 0 ? path : path.slice(0, at); if (path.includes("#")) throw new Refused(`${path}: a path with a fragment is not sent`); const segments = route.replace(/^\/+/, "").split("/"); for (const s of segments) { let decoded: string; try { decoded = decodeURIComponent(s); } catch { throw new Refused(`${path}: a segment that does not decode is not sent`); } if (decoded === "" || decoded === "." || decoded === ".." || decoded.includes("?")) { throw new Refused(`${path}: an empty or dot segment is not sent. Nothing was sent`); } } if (at >= 0) sentQuery(path.slice(at)); }