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
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:
+55
-3
@@ -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"]);
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) } : {}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user