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
+165
View File
@@ -0,0 +1,165 @@
// What the forge's tools refuse (guard.ts; novox/hq ADR 0266): an agent reaches the forge through these tools
// with a token minted for the forge's administrator, so the tools — not the forge — are what keeps it from
// dropping a branch's protection, writing to main, setting the merge check's status, or merging past the
// checks. Each route a review found is reproduced here against a fake forge, and each is refused with nothing
// sent; the operator-approved merge of a pull request whose checks passed still goes through.
import { test, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { GiteaClient } from "../dist/client.js";
import { ConfiguredToken } from "../dist/token.js";
import { getGiteaTools } from "../dist/tools/index.js";
import { judgeApiCall, judgeProtectionChange, ruleCovers, routedPath } from "../dist/guard.js";
interface Sent { method: string; path: string; body: any }
const state = {
sent: [] as Sent[],
statuses: [] as { context: string; state: string }[],
rules: [] as any[],
};
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 });
beforeEach(() => {
state.sent = [];
state.statuses = [{ context: "mesh/merge-gate", state: "success" }, { context: "mesh/repo-check", state: "success" }];
state.rules = [protectedMain()];
});
const json = (res: ServerResponse, status: number, body: unknown): void => {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(body === null ? "" : JSON.stringify(body));
};
const read = (req: IncomingMessage): Promise<any> =>
new Promise((resolve) => {
let text = "";
req.on("data", (c) => (text += c));
req.on("end", () => resolve(text ? JSON.parse(text) : null));
});
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://fake");
const path = url.pathname.replace(/^\/api\/v1/, "");
const method = req.method ?? "GET";
const body = await read(req);
if (method !== "GET") state.sent.push({ method, path, body });
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 === `${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" });
}
if (method === "GET" && path === `${repo}/commits/abc1234/status`) {
return json(res, 200, { sha: "abc1234", state: "success", statuses: state.statuses });
}
if (method === "POST" && path === `${repo}/pulls/7/merge`) return json(res, 200, null);
return json(res, method === "GET" ? 200 : 201, {});
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
after(() => new Promise<void>((r) => server.close(() => r())));
const { port } = server.address() as { port: number };
const tools = getGiteaTools(new GiteaClient(`http://127.0.0.1:${port}`, new ConfiguredToken("t")));
const tool = (name: string) => tools.find((t) => t.name === name)!;
const writes = () => state.sent.map((s) => `${s.method} ${s.path}`);
test("gitea_api refuses the forge's administration, a branch's protection and the merge check's status, and sends nothing", async () => {
for (const [method, path, body] of [
["PATCH", "/admin/users/agent", { admin: true }],
["DELETE", "/repos/novox/mesh-catalog/branch_protections/main", undefined],
["PATCH", "/repos/novox/mesh-catalog/branch_protections/main", { enable_status_check: false }],
["POST", "/repos/novox/mesh-catalog/statuses/abc1234", { context: "mesh/merge-gate", state: "success" }],
["POST", "/repos/novox/mesh-catalog/pulls/7/merge", { Do: "merge", force_merge: true }],
["PUT", "/repos/novox/mesh-catalog/collaborators/agent", { permission: "admin" }],
["POST", "/repos/novox/mesh-catalog/hooks", { type: "gitea" }],
["POST", "/repos/novox/mesh-catalog/keys", { key: "ssh-ed25519 AAAA" }],
["PATCH", "/repos/novox/mesh-catalog", { default_branch: "agent" }],
["DELETE", "/repos/novox/mesh-catalog", undefined],
["POST", "/repos/novox/mesh-catalog/transfer", { new_owner: "agent" }],
["POST", "/users/mesh-admin/tokens", { name: "mine", scopes: ["all"] }],
["PUT", "/orgs/novox/members/agent", undefined],
["PATCH", "/repos/novox/mesh-catalog/issues/1/../../branch_protections/main", { enable_push: true }],
["PATCH", "/repos/novox/mesh-catalog/issues/1/%2e%2e/%2e%2e/branch_protections/main", { enable_push: true }],
] as const) {
await assert.rejects(tool("gitea_api").run({ path, method, ...(body ? { body } : {}) }),
/not a write gitea_api makes|not taken|administration/, `${method} ${path}`);
}
await assert.rejects(tool("gitea_api").run({ path: "/admin/users" }), /administration/);
assert.deepEqual(writes(), []);
});
test("gitea_api refuses a file written to the default branch or one a rule guards, and writes one on a branch of its own", async () => {
const put = (body: Record<string, unknown>) =>
tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/contents/modules/sudo/module.json", method: "PUT", body });
await assert.rejects(put({ content: "e30=", sha: "x" }), /default branch/);
await assert.rejects(put({ content: "e30=", sha: "x", branch: "main" }), /default branch/);
state.rules.push({ rule_name: "release/*" });
await assert.rejects(put({ content: "e30=", sha: "x", branch: "release/1" }), /protection rule release\/\*/);
assert.deepEqual(writes(), []);
await put({ content: "e30=", sha: "x", branch: "main", new_branch: "feat/sudo-docs" });
assert.deepEqual(writes(), ["PUT /repos/novox/mesh-catalog/contents/modules/sudo/module.json"]);
});
test("gitea_api still writes what an author writes", async () => {
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/issues/3/comments", method: "POST", body: { body: "hi" } });
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/pulls/7/reviews", method: "POST", body: { event: "COMMENT" } });
await tool("gitea_api").run({ path: "/repos/novox/mesh-catalog/branch_protections" });
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/issues/3/comments", "POST /repos/novox/mesh-catalog/pulls/7/reviews"]);
});
test("gitea_branch_protection_set only strengthens", async () => {
const set = (args: Record<string, unknown>) =>
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "main", ...args });
await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate, mesh\/repo-check/);
await assert.rejects(set({ status_checks: "mesh/merge-gate" }), /drops the required status mesh\/repo-check/);
await assert.rejects(set({ status_checks: "mesh/merge-gate,mesh/repo-check", push: true }), /push to the branch directly/);
await assert.rejects(set({ status_checks: "mesh/merge-gate,mesh/repo-check", block_admin_override: false }),
/merge past a status/);
assert.deepEqual(writes(), []);
await set({ status_checks: "mesh/merge-gate,mesh/repo-check,mesh/delivery" });
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog/branch_protections/main"]);
});
test("gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head", async () => {
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7 });
state.statuses = [{ context: "mesh/merge-gate", state: "success" }, { context: "mesh/repo-check", state: "failure" }];
await assert.rejects(merge(), /not merged: .*mesh\/repo-check \(failure\)/);
state.statuses = [{ context: "mesh/merge-gate", state: "success" }];
await assert.rejects(merge(), /mesh\/repo-check \(missing\)/);
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7,
method: "manually-merged" }), /not a merge method/);
assert.deepEqual(writes(), []);
// The operator said yes and the checks passed: the flow that stays.
state.statuses = [{ context: "mesh/merge-gate", state: "success" }, { context: "mesh/repo-check", state: "success" }];
await merge();
const sent = state.sent.find((s) => s.path.endsWith("/pulls/7/merge"))!;
assert.equal(sent.body.force_merge, false);
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/);
assert.deepEqual(writes(), []);
});
test("a repository owner or name is never another path", async () => {
await assert.rejects(tool("gitea_update_pull_request").run({ owner: "novox", repo: "mesh-catalog/branch_protections/main?",
number: 1, title: "x" }), /not a repository name/);
await assert.rejects(tool("gitea_get_issue").run({ owner: "..", repo: "admin", number: 1 }), /not a repository owner/);
assert.deepEqual(writes(), []);
});
test("the judges, without a forge", () => {
assert.equal(routedPath("/api/v1/repos/a/b/issues?x=1"), "/repos/a/b/issues");
assert.throws(() => routedPath("/repos/a/b/../c"), /dot segment/);
assert.ok("allowed" in judgeApiCall("GET", "/repos/a/b/branch_protections", undefined));
assert.ok("refused" in judgeApiCall("POST", "/repos/a/b/branch_protections", {}));
assert.ok("contents" in judgeApiCall("POST", "/repos/a/b/contents", { new_branch: "x" }));
assert.ok(ruleCovers("release/**", "release/a/b") && ruleCovers("feat/*", "feat/a") && !ruleCovers("feat/*", "feat/a/b"));
assert.equal(judgeProtectionChange(null, { statusChecks: [] }), null);
assert.equal(judgeProtectionChange(protectedMain(), { statusChecks: ["mesh/repo-check", "mesh/merge-gate"] }), null);
});
+31 -16
View File
@@ -17,7 +17,7 @@ import { join } from "node:path";
import { collectTools } from "@novox/mesh-sdk/tools";
import { AdminRefused, MintedToken, TOKEN_SCOPES } from "../dist/token.js";
import { GiteaClient } from "../dist/client.js";
import "../dist/tools/index.js";
import { getGiteaTools } from "../dist/tools/index.js";
const ADMIN = "mesh-admin";
const PASSWORD = "the-vault-minted-this";
@@ -230,10 +230,12 @@ test("first start: mints with the admin account, keeps the token at 0600, asks f
assert.equal(repos[0]?.full_name, "novox/hq");
assert.equal(forge.mints, 1);
assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user", "write:admin"]);
// Never write:admin (novox/hq ADR 0266): the forge's administration is the operator's, not a tool's.
assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user"]);
assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
const token = forge.tokens.get("mesh-tools")!;
assert.equal(await readFile(file, "utf8"), token + "\n");
assert.equal(await readFile(file + ".scopes", "utf8"), "read:user,write:issue,write:repository\n");
assert.equal((await stat(file)).mode & 0o777, 0o600);
// Said that it minted, and where it keeps it — never what it is.
assert.ok(logs.some((l) => l.startsWith("minted a token")), logs.join("\n"));
@@ -268,32 +270,45 @@ test("the forge rejects the kept token (its data was restored): minted afresh, o
assert.ok(logs.some((l) => l.startsWith("the forge rejected the kept token")), logs.join("\n"));
});
test("a kept token from before write:admin: the forge refuses the admin route for the scope, the token is re-minted with the whole list, and the call goes through", async () => {
test("a token kept with write:admin by an earlier build is minted again without it at the next start, and the forge drops the old one", async () => {
const { env, file, logs } = await delivered(forge);
await minted(env, logs).listRepos();
const old = forge.tokens.get("mesh-tools")!;
forge.scopesOf.set(old, ["write:repository", "write:issue", "read:user", "write:admin"]);
await rm(file + ".scopes"); // an earlier build kept no scopes beside it
const before = forge.mints;
const again: string[] = [];
await minted(env, again).listRepos();
assert.equal(forge.mints, before + 1);
assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
assert.ok(!forge.lastScopes!.includes("write:admin"));
assert.notEqual(forge.tokens.get("mesh-tools"), old);
assert.ok(![...forge.tokens.values()].includes(old), "the old token still stands on the forge");
assert.ok(again.some((l) => l.includes("minted with other scopes")), again.join("\n"));
// An admin route through the escape hatch is refused before it is sent.
const tool = getGiteaTools(minted(env, again)).find((t) => t.name === "gitea_api")!;
await assert.rejects(tool.run({ path: "/admin/users/mesh_novox_builder", method: "PATCH", body: { admin: true } }),
/not a write gitea_api makes/);
});
test("a kept token lacking a scope the tools need: the forge refuses the call for the scope, the token is re-minted with the whole list, and the call goes through", async () => {
const { env, file, logs } = await delivered(forge);
const client = minted(env, logs);
await client.listRepos();
const before = forge.mints;
const old = forge.tokens.get("mesh-tools")!;
forge.scopesOf.set(old, ["write:repository", "write:issue", "read:user"]); // minted by the previous build
forge.scopesOf.set(old, ["write:repository", "write:issue"]); // minted before read:user was added
const user = await client.api<{ login: string; is_admin: boolean }>("/admin/users/mesh_novox_builder", {
method: "PATCH",
body: JSON.stringify({ admin: true }),
});
const repos = await client.api<unknown[]>("/user/repos");
assert.equal(user.is_admin, true);
assert.equal(repos.length, 1);
assert.equal(forge.mints, before + 1);
assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
assert.notEqual(forge.tokens.get("mesh-tools"), old);
assert.equal(await readFile(file, "utf8"), forge.tokens.get("mesh-tools") + "\n");
assert.ok(logs.some((l) => l.startsWith("the forge rejected the kept token")), logs.join("\n"));
// A 403 that is not about scopes is the forge's answer, not a reason to mint.
const again = forge.mints;
await assert.rejects(
client.api("/admin/users/untouchable", { method: "PATCH", body: JSON.stringify({ admin: true }) }),
/403 .*untouchable/,
);
assert.equal(forge.mints, again);
});
test("the kept file is gone but the forge still holds a token by that name: replaced, not refused", async () => {