Files
mesh-catalog/modules/gitea/guard.ts
T
jschoubben 32655b95f6 GIT1, GIT2, GIT3, GIT10: the forge module serves the seat git's verbs and says its events, retires its acting tools, follows the catalog half
The four share files (index.ts, module.json, guard.test.ts), so they land as one commit.
GIT1: seat.ts serves pull, open-pulls, tags, trees, file, contains, statuses, status, comment, view, note, merge-pull
as tools on the seat, each answering the contract's shape and an error of kind refused, not-found or failed; a comment,
view or note whose act id is held is answered held (note matched by id). merge-pull merges the head the caller names
(its from-G judgements are GIT6, GIT7, GIT9).
GIT2: seatevents.ts says pull-updated, pull-merged, pull-closed, forge-pull, forge-status, forge-delete and repo-writable on
mesh.seat.git.event.<event>; the module's emits drop them.
GIT3: merge.ts, check.ts, the acting tools, the owed watch, the gate's source read and check-gates-merge are gone.
GIT10: catalog.ts follows the seat mesh-catalog (revision, catalog-revision); no write is granted before the first view.

Builds: design 47 G §310, §311, §312, §313, §314, §163, §317, §278, §321, §196

Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
2026-11-11 11:31:24 +01:00

425 lines
22 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` lets no person push and no administrator merge past a rule, and on main keeps
// the tools' account the only one that merges; it requires no status, as no rule of the mesh does (ADR 0534 §5);
// - `gitea_default_branch_set` and `gitea_pull_base_set` are the two settings gitea_api refuses, as named tools: a branch that
// exists (and, for a default, that a rule guards), a stated reason, the tools' account, the value before and after (issue 1422);
// - the seat git's `merge-pull` (seat.ts) merges only into a branch that refuses its push, and only the head the
// operator's Approve names (ADR 0661);
// - `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 { MAIN_BRANCH, mainMergers, type BranchProtection, type 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",
// Read flags of the forge's tree listing (gitea_commit_trees, novox/hq issue 1234): they widen what one read answers, never what a call may write.
"recursive", "per_page",
]);
/** 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 (the seat git's merge-pull and status, ` +
`gitea_branch_protection_set, which never lets a person push). 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. A required status taken away is no weakening: no rule of the mesh requires one (ADR 0534 §5).
*/
export function judgeProtectionChange(before: BranchProtection | null, want: ProtectionWanted,
covering: BranchProtection[] = [], branch = ""): string | null {
const isMain = branch === MAIN_BRANCH || (before !== null && (before.rule_name ?? before.branch_name) === MAIN_BRANCH);
const main = isMain ? judgeMain(before, want) : null;
if (main) return main;
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 why: string[] = [];
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 what the rule asks");
}
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 landing but through a pull request the operator approved (novox/hq ADR 0266, ADR 0534) — weakening it is the ` +
`operator's, at the forge as themselves. Nothing was changed`;
}
/** Main's rule takes no push and lets only the tools' account and main-mergers merge (novox/hq ADR 0398 §2, issue 948,
* ADR 0534 §2): a change that enables push, drops the merge whitelist or widens it past them is refused, whatever the
* rule was. */
function judgeMain(before: BranchProtection | null, want: ProtectionWanted): string | null {
const why: string[] = [];
if (want.push === true) why.push("it lets a person push to the branch directly");
if (want.mergeWhitelist !== undefined) {
const names = want.mergeWhitelist ?? [];
if (names.length === 0) why.push("it drops the merge whitelist of main");
else if (names.some((n) => !mainMergers().includes(n))) {
why.push(`it widens who may merge into main beyond ${mainMergers().join(", ")}`);
}
}
if (!why.length) return null;
return `the protection of ${before?.rule_name ?? before?.branch_name ?? "main"} is only made stronger through a tool: ` +
`${why.join("; ")}. Main takes no push, and only ${mainMergers().join(" and ")} merge into it (novox/hq ADR 0398 §2, ADR 0534 §2) — ` +
`weakening that 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));
}