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 delivered
Review 30 found guard.ts missing branches the forge protects: Go reads \d, \w, \pL, a class opening with ] or ^, and [[:digit:]] otherwise than JavaScript. A rule using any of them now covers every branch, so the guard refuses more than the forge, never less. test/rule-cases.json holds the forge's answers, read by the Go and TypeScript tests alike.
402 lines
20 KiB
TypeScript
402 lines
20 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;
|
|
}
|
|
|
|
/**
|
|
* 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<string> = 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<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) } };
|
|
}
|
|
// 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<string, unknown>)) {
|
|
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<string, unknown> | 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));
|
|
}
|