mesh/merge-gate pass: builds gitea → novox; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery superseded: a newer head of the same pull request
gitea_api took any endpoint with a token minted for the forge's administrator with write:admin: an agent could drop a branch's protection or its required checks, set the merge check's status, write to main or merge its own pull request (novox/hq ADR 0266 §7, the review of 2026-10-09). gitea_api now reads anything but the administration and writes only what an author writes; protection is only strengthened; a merge needs every required status a success and merges only that head; a guarded repository is not deleted; and the token is minted without write:admin, a kept one re-minted at the next start.
179 lines
9.5 KiB
TypeScript
179 lines
9.5 KiB
TypeScript
// 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<string, unknown>;
|
|
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;
|
|
}
|