Send the path gitea_api judged, keep a pull request's base, merge only into a guarded branch, and delete only the tools' own idle repositories
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

Follow-ups of the review of 2026-10-09: the escape hatch sent the raw path it
had judged in normalised form; a pull request's base could be moved through
it; the merge tool would merge into a branch no rule guards; a rule named for
a branch could undercut the glob rule guarding it; and the delete tool took
any unguarded repository. It now takes only a repository of the tools' own
user with no protection and no commit in 30 days, named in full.
This commit is contained in:
jochen
2026-10-09 12:26:37 +02:00
parent 080c113d69
commit ff648ad32b
3 changed files with 128 additions and 20 deletions
+55 -3
View File
@@ -104,6 +104,12 @@ export function judgeApiCall(method: string, rawPath: string, body: unknown): Ap
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` };
}
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 ` +
@@ -146,9 +152,25 @@ export function judgeContentsWrite(target: string, defaultBranch: string, rules:
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.
/**
* 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));
@@ -165,6 +187,36 @@ export function judgeProtectionChange(before: BranchProtection | null, want: Pro
`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"]);
+46 -3
View File
@@ -19,6 +19,7 @@ const state = {
sent: [] as Sent[],
statuses: [] as { context: string; state: string }[],
rules: [] as any[],
lastCommit: "2026-01-01T00:00:00Z",
};
const protectedMain = () => ({ rule_name: "main", enable_push: false, enable_status_check: true,
status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"], block_admin_merge_override: true });
@@ -49,6 +50,11 @@ const server = createServer(async (req, res) => {
const repo = "/repos/novox/mesh-catalog";
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: "main" });
if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules);
if (method === "GET" && path === "/user") return json(res, 200, { login: "mesh-admin" });
if (method === "GET" && path === "/repos/mesh-admin/scratch/branch_protections") return json(res, 200, []);
if (method === "GET" && /^\/repos\/[^/]+\/[^/]+\/commits$/.test(path)) {
return json(res, 200, [{ commit: { committer: { date: state.lastCommit } } }]);
}
if (method === "GET" && path === `${repo}/pulls/7`) {
return json(res, 200, { number: 7, title: "x", state: "open", merged: false, head: { ref: "feat/x", sha: "abc1234" },
base: { ref: "main" }, html_url: "http://fake/7" });
@@ -140,10 +146,47 @@ test("gitea_merge_pull_request merges only a head whose required statuses all su
assert.equal(sent.body.head_commit_id, "abc1234");
});
test("gitea_delete_repo refuses a repository a protection rule guards", async () => {
await assert.rejects(tool("gitea_delete_repo").run({ owner: "novox", name: "mesh-catalog", confirm: true }),
/guarded by the protection rule main/);
test("gitea_delete_repo deletes only the tools' own user's repository, unguarded, untouched for 30 days, named in full", async () => {
const del = (owner: string, name: string, confirm: unknown) => tool("gitea_delete_repo").run({ owner, name, confirm });
await assert.rejects(del("novox", "mesh-catalog", true), /confirm must be the repository's full name/);
await assert.rejects(del("novox", "mesh-catalog", "novox/mesh-catalog"), /not a repository of the tools' own user/);
state.lastCommit = new Date().toISOString();
await assert.rejects(del("mesh-admin", "scratch", "mesh-admin/scratch"), /within 30 days/);
assert.deepEqual(writes(), []);
state.lastCommit = "2026-01-01T00:00:00Z";
await del("mesh-admin", "scratch", "mesh-admin/scratch");
assert.deepEqual(writes(), ["DELETE /repos/mesh-admin/scratch"]);
});
test("gitea_api sends the path it judged, and refuses moving a pull request's base", async () => {
await tool("gitea_api").run({ path: "/api/v1/repos/novox/mesh-catalog/issues/3/comments/?x=1", method: "POST", body: { body: "hi" } });
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/issues/3/comments"]);
await assert.rejects(tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7", method: "PATCH",
body: { base: "unguarded" } }), /changing a pull request's base/);
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7", method: "PATCH", body: { title: "x" } });
assert.equal(writes().length, 2);
});
test("gitea_merge_pull_request merges only into a branch whose protection requires the checks", async () => {
state.rules = [];
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7 }),
/no protection rule requiring the checks guards/);
state.rules = [{ rule_name: "main", enable_push: false, enable_status_check: false, status_check_contexts: [] }];
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7 }),
/no protection rule requiring the checks guards/);
assert.deepEqual(writes(), []);
});
test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => {
state.rules.push({ rule_name: "release/*", enable_push: false, enable_status_check: true,
status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true });
const set = (args: Record<string, unknown>) =>
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "release/1", ...args });
await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate/);
await assert.rejects(set({ status_checks: "mesh/merge-gate" }), /merge past a status/);
assert.deepEqual(writes(), []);
await set({ status_checks: "mesh/merge-gate", block_admin_override: true });
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/branch_protections"]);
});
test("a repository owner or name is never another path", async () => {
+27 -14
View File
@@ -14,7 +14,8 @@ 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,
MERGE_METHODS, Refused, checkName, judgeApiCall, judgeContentsWrite, judgeDelete, judgeProtectionChange, routedPath,
ruleCovers,
} from "../guard.js";
/** The forge's container, where its repositories and git are: the note is written there (novox/hq ADR 0239). */
@@ -94,22 +95,23 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
},
{
name: "gitea_delete_repo",
description: "Delete a repository. Destructive and irreversible — requires confirm=true.",
description: "Delete a repository of the tools' own user that no protection rule guards and that had no commit " +
"in 30 days. Destructive and irreversible — confirm must be the repository's full name, owner/name. An " +
"organisation's or a person's repository is the operator's to delete, at the forge (novox/hq ADR 0266).",
input: {
owner: { type: "string", description: "the repository owner" },
name: { type: "string", description: "the repository name" },
confirm: { type: "boolean", description: "must be true to actually delete" },
confirm: { type: "string", description: "the repository's full name, owner/name, to actually delete it" },
},
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`);
}
const owner = String(args.owner), name = String(args.name), full = `${owner}/${name}`;
const self = (await gitea.api<{ login?: string }>("/user"))?.login ?? "";
const commits = await gitea.api<Array<{ created?: string; commit?: { committer?: { date?: string } } }>>(
`/repos/${owner}/${name}/commits?limit=1`).catch(() => []);
const when = commits?.[0]?.commit?.committer?.date ?? commits?.[0]?.created;
const refused = judgeDelete(full, args.confirm, owner, self, await gitea.branchProtections(owner, name),
when ? new Date(when) : null, new Date());
if (refused) throw new Refused(refused);
await gitea.deleteRepo(String(args.owner), String(args.name));
return { deleted: true, repo: `${String(args.owner)}/${String(args.name)}` };
},
@@ -284,6 +286,12 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
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);
// Only into a branch whose protection requires the checks: a pull request whose base was moved to a
// branch nothing guards would merge past every check (novox/hq ADR 0266).
if (!rule || !rule.enable_status_check || !(rule.status_check_contexts ?? []).filter(Boolean).length) {
throw new Refused(`${owner}/${repo}#${number} merges into ${pull.base}, which no protection rule requiring ` +
`the checks guards: a tool merges only into such a branch (novox/hq ADR 0266). Nothing was merged`);
}
const statuses = newestByContext((await gitea.combinedStatus(owner, repo, pull.head_sha)).statuses);
const verdict = mergeableUnder(pull.base, rule, statuses);
if (verdict.mergeable !== true) {
@@ -459,7 +467,9 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
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);
const covering = before ? [] : (await gitea.branchProtections(owner, repo))
.filter((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), branch));
const weakens = judgeProtectionChange(before, want, covering);
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) };
@@ -633,7 +643,10 @@ function allGiteaTools(gitea: GiteaClient): ToolDefinition[] {
await gitea.branchProtections(owner, repo));
if (refused) throw new Refused(refused);
}
const result = await gitea.api(String(args.path), {
// The path judged is the path sent: normalised, its query kept.
const raw = String(args.path ?? "");
const query = raw.includes("?") ? raw.slice(raw.indexOf("?")).split("#")[0] : "";
const result = await gitea.api(routedPath(raw) + query, {
method,
...(args.body ? { body: JSON.stringify(args.body) } : {}),
});