Let the forge's tools never weaken protection or merge past the checks
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.
This commit is contained in:
jochen
2026-10-09 11:47:36 +02:00
parent 7339ce8556
commit 080c113d69
6 changed files with 476 additions and 31 deletions
+70 -7
View File
@@ -13,6 +13,9 @@ import { emit } from "@novox/mesh-sdk/events";
import { GiteaClient, type BranchProtection } from "../client.js";
import { appendNote, deliveryStatus, run, viewBody, viewComment } from "../delivery.js";
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
import {
MERGE_METHODS, Refused, checkName, judgeApiCall, judgeContentsWrite, judgeProtectionChange,
} from "../guard.js";
/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). */
const forgeContainer = process.env.MESH_GITEA_CONTAINER || "gitea";
@@ -37,7 +40,26 @@ function parseLabels(raw: unknown): string[] {
.filter(Boolean);
}
/** Every tool's repository owner and name checked before it runs: a name is never another path (guard.ts). */
function namesChecked(tools: ToolDefinition[]): ToolDefinition[] {
return tools.map((t) => ({
...t,
run: async (args: Record<string, unknown>) => {
if (args.owner !== undefined) checkName("owner", args.owner);
if (args.repo !== undefined) checkName("name", args.repo);
if ((t.name === "gitea_create_repo" || t.name === "gitea_delete_repo") && args.name !== undefined) {
checkName("name", args.name);
}
return t.run(args);
},
}));
}
export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
return namesChecked(allGiteaTools(gitea));
}
function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
return [
// ---- Repositories ----
{
@@ -80,6 +102,14 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
},
run: async (args) => {
if (!args.confirm) return { deleted: false, reason: "confirm must be true to delete a repository" };
// A repository a protection rule guards is the mesh's: deleted and made again, it would be one without
// its protection, under the name the mesh builds from (novox/hq ADR 0266).
const guarded = await gitea.branchProtections(String(args.owner), String(args.name));
if (guarded.length) {
throw new Refused(`${String(args.owner)}/${String(args.name)} is guarded by the protection rule ` +
`${guarded.map((r) => r.rule_name ?? r.branch_name).join(", ")}: deleting it is the operator's, at the forge ` +
`as themselves, never a tool's (novox/hq ADR 0266). Nothing was deleted`);
}
await gitea.deleteRepo(String(args.owner), String(args.name));
return { deleted: true, repo: `${String(args.owner)}/${String(args.name)}` };
},
@@ -230,7 +260,9 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
},
{
name: "gitea_merge_pull_request",
description: "Merge a pull request, optionally deleting the source branch afterwards.",
description: "Merge a pull request whose head has every status its base branch's protection requires as a success " +
"(mesh/merge-gate, mesh/repo-check), optionally deleting the source branch afterwards. Refused otherwise — no " +
"tool merges past a check — and only the head that was checked is merged.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
@@ -243,8 +275,22 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
const repo = String(args.repo);
const number = Number(args.number);
const method = args.method ? String(args.method) : "merge";
if (!MERGE_METHODS.has(method)) throw new Refused(`${method} is not a merge method: ${[...MERGE_METHODS].join(", ")}`);
const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch);
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
// **Only a head whose required statuses all succeeded** (novox/hq ADR 0266): read here rather than left
// to the forge, whose administrator — the account this tool's token is minted for — may merge past a
// rule that lets it. A rule that cannot be read is no rule passed.
const pull = await gitea.getPullRequest(owner, repo, number);
if (pull.merged) throw new Refused(`${owner}/${repo}#${number} is already merged`);
if (!pull.head_sha || !pull.base) throw new Refused(`${owner}/${repo}#${number} names no head commit or base branch`);
const rule = await gitea.branchProtection(owner, repo, pull.base);
const statuses = newestByContext((await gitea.combinedStatus(owner, repo, pull.head_sha)).statuses);
const verdict = mergeableUnder(pull.base, rule, statuses);
if (verdict.mergeable !== true) {
throw new Refused(`${owner}/${repo}#${number} is not merged: ${verdict.says}. A pull request merges once ` +
`its checks succeed; no tool merges past them (novox/hq ADR 0266)`);
}
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch, pull.head_sha);
// The merge commit only exists now; answered so the caller can follow what is built from it.
// pull.merged is the events entrypoint's to announce (index.ts), once, within its poll.
const merged = await gitea.getPullRequest(owner, repo, number);
@@ -395,7 +441,7 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
},
{
name: "gitea_branch_protection_set",
description: "Make a branch require commit statuses before a pull request merges into it — the mesh's merge check sets `mesh/merge-gate` (the module graph's gate) and `mesh/repo-check` (the repository's own tests). Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.",
description: "Only ever stronger (novox/hq ADR 0266): a change that drops a required status, lets a person push directly or lets an administrator merge past a status is refused — that is the operator's, at the forge. Make a branch require commit statuses before a pull request merges into it — the mesh's merge check sets `mesh/merge-gate` (the module graph's gate) and `mesh/repo-check` (the repository's own tests). Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
@@ -408,11 +454,14 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim();
if (!branch) throw new Error("name the branch to protect");
const before = await gitea.branchProtection(owner, repo, branch);
const { created, rule } = await gitea.setBranchProtection(owner, repo, branch, {
const want = {
statusChecks: String(args.status_checks ?? "").split(","),
push: args.push === undefined ? undefined : Boolean(args.push),
blockAdminOverride: args.block_admin_override === undefined ? undefined : Boolean(args.block_admin_override),
});
};
const weakens = judgeProtectionChange(before, want);
if (weakens) throw new Refused(weakens);
const { created, rule } = await gitea.setBranchProtection(owner, repo, branch, want);
return { branch, created, before: before ? summarised(before) : null, after: summarised(rule) };
},
},
@@ -562,14 +611,28 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
// ---- Escape hatch ----
{
name: "gitea_api",
description: "Make an authenticated Gitea API call for any endpoint without a dedicated tool. Path is relative to /api/v1.",
description: "Make an authenticated Gitea API call for an endpoint without a dedicated tool. Path is relative to /api/v1. " +
"Reads anything but the forge's administration; writes only what an author writes — issues, comments, labels, " +
"pull requests (not their merge: gitea_merge_pull_request), branches, and files on a branch that is not the " +
"default branch and that no protection rule names. Administration, protection, settings, collaborators, teams, " +
"hooks, keys, tokens and statuses are refused (novox/hq ADR 0266).",
input: {
path: { type: "string", description: "API path relative to /api/v1, e.g. /repos/owner/repo/branches" },
method: { type: "string", description: "GET | POST | PUT | PATCH | DELETE (default GET)" },
body: { type: "object", description: "JSON request body for POST/PUT/PATCH" },
},
run: async (args) => {
const method = args.method ? String(args.method) : "GET";
const method = (args.method ? String(args.method) : "GET").toUpperCase();
const judged = judgeApiCall(method, String(args.path ?? ""), args.body);
if ("refused" in judged) throw new Refused(judged.refused);
if ("contents" in judged) {
const { owner, repo, branch, newBranch } = judged.contents;
const found = await gitea.api<{ default_branch?: string }>(`/repos/${checkName("owner", owner)}/${checkName("name", repo)}`);
const defaultBranch = String(found?.default_branch ?? "main");
const refused = judgeContentsWrite(newBranch ?? branch ?? defaultBranch, defaultBranch,
await gitea.branchProtections(owner, repo));
if (refused) throw new Refused(refused);
}
const result = await gitea.api(String(args.path), {
method,
...(args.body ? { body: JSON.stringify(args.body) } : {}),