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
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:
@@ -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) } : {}),
|
||||
|
||||
Reference in New Issue
Block a user