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:
@@ -463,10 +463,14 @@ export class GiteaClient {
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false): Promise<void> {
|
||||
/** Merge a pull request — never forced past its checks, and, given the head that was checked, only that head:
|
||||
* the forge refuses the merge when the branch moved since (novox/hq ADR 0266). */
|
||||
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false,
|
||||
headCommit?: string): Promise<void> {
|
||||
await this.request(`/repos/${owner}/${repo}/pulls/${index}/merge`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch }),
|
||||
body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch, force_merge: false,
|
||||
...(headCommit ? { head_commit_id: headCommit } : {}) }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// What the forge's tools refuse (novox/hq ADR 0266): whoever may call a tool includes agents, and the token
|
||||
// the tools hold is minted for the forge's administrator. Through `gitea_api` any endpoint was one call away:
|
||||
// an agent could drop a branch's protection or its required checks, write to main, set the merge check's
|
||||
// status, or merge its own pull request — and a module merged into the catalogue is what the next push sends,
|
||||
// as root, to every node. So:
|
||||
//
|
||||
// - `gitea_api` reads anything but the forge's administration, and writes only what an author writes:
|
||||
// issues, comments, labels, pull requests (opened, edited, reviewed — never merged), branches, and a
|
||||
// file on a branch that is neither the repository's default branch nor one a protection rule names.
|
||||
// Everything else — administration, protection, settings, collaborators, teams, hooks, keys, tokens,
|
||||
// statuses, a merge, a repository deleted or moved — has its own tool or is the operator's, at the
|
||||
// forge as themselves;
|
||||
// - `gitea_branch_protection_set` only makes protection stronger;
|
||||
// - `gitea_merge_pull_request` merges only a head whose every required status succeeded, and only that
|
||||
// head;
|
||||
// - `gitea_delete_repo` refuses a repository any protection rule guards.
|
||||
//
|
||||
// Pure, so it is tested without a forge; the tools do the asking (tools/index.ts).
|
||||
|
||||
import type { BranchProtection, ProtectionWanted } from "./protection.js";
|
||||
|
||||
/** A refusal of policy, in the words the caller sees. Never an error of the forge's. */
|
||||
export class Refused extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "Refused";
|
||||
}
|
||||
}
|
||||
|
||||
/** What `gitea_api` may do with one call, as far as the call alone says. */
|
||||
export type ApiJudgement =
|
||||
| { allowed: true }
|
||||
| { refused: string }
|
||||
/** A file written: allowed only on a branch that is not the default branch and that no rule names. */
|
||||
| { contents: { owner: string; repo: string; branch?: string; newBranch?: string } };
|
||||
|
||||
const READS = new Set(["GET", "HEAD"]);
|
||||
|
||||
const seg = "([^/]+)";
|
||||
const repoPath = `^/repos/${seg}/${seg}`;
|
||||
|
||||
/** The writes an author makes, by method and path. Anything not here is refused. */
|
||||
const AUTHOR_WRITES: ReadonlyArray<{ methods: string[]; path: RegExp }> = [
|
||||
{ methods: ["POST"], path: new RegExp(`${repoPath}/issues$`) },
|
||||
{ methods: ["PATCH"], path: new RegExp(`${repoPath}/issues/\\d+$`) },
|
||||
{ methods: ["POST"], path: new RegExp(`${repoPath}/issues/\\d+/comments$`) },
|
||||
{ methods: ["PATCH", "DELETE"], path: new RegExp(`${repoPath}/issues/comments/\\d+$`) },
|
||||
{ methods: ["POST", "PUT", "DELETE"], path: new RegExp(`${repoPath}/issues/\\d+/labels(/\\d+)?$`) },
|
||||
{ methods: ["POST"], path: new RegExp(`${repoPath}/labels$`) },
|
||||
{ methods: ["PATCH", "DELETE"], path: new RegExp(`${repoPath}/labels/\\d+$`) },
|
||||
{ methods: ["POST"], path: new RegExp(`${repoPath}/pulls$`) },
|
||||
{ methods: ["PATCH"], path: new RegExp(`${repoPath}/pulls/\\d+$`) },
|
||||
{ methods: ["POST"], path: new RegExp(`${repoPath}/pulls/\\d+/reviews(/\\d+(/comments)?)?$`) },
|
||||
{ methods: ["POST", "DELETE"], path: new RegExp(`${repoPath}/pulls/\\d+/requested_reviewers$`) },
|
||||
{ methods: ["POST"], path: new RegExp(`${repoPath}/branches$`) },
|
||||
{ methods: ["POST"], path: /^\/user\/repos$/ },
|
||||
{ methods: ["POST"], path: /^\/markdown(\/raw)?$/ },
|
||||
];
|
||||
|
||||
/** A file written through the contents API: one file, or several in one commit. */
|
||||
const CONTENTS = new RegExp(`${repoPath}/contents(/.*)?$`);
|
||||
|
||||
/** Reads that are no one's but the administrator's. */
|
||||
const ADMIN_READS = [/^\/admin(\/|$)/, /^\/users\/[^/]+\/tokens(\/|$)/];
|
||||
|
||||
/**
|
||||
* The path as the forge would route it: relative to /api/v1, its query dropped, refused when it could be read
|
||||
* as another path — a dot segment, an encoded slash or dot, a backslash, an empty segment. A path the forge and
|
||||
* this judge could read differently is never judged.
|
||||
*/
|
||||
export function routedPath(raw: string): string {
|
||||
let path = String(raw ?? "").trim();
|
||||
if (!path.startsWith("/")) path = "/" + path;
|
||||
path = path.replace(/^\/api\/v1(?=\/|$)/, "") || "/";
|
||||
const q = path.search(/[?#]/);
|
||||
if (q >= 0) path = path.slice(0, q);
|
||||
if (/%2e|%2f|%5c|\\/i.test(path)) throw new Refused(`${raw}: an encoded dot, slash or backslash is not taken`);
|
||||
path = path.replace(/\/+$/, "") || "/";
|
||||
const segments = path.split("/").slice(1);
|
||||
if (path !== "/" && segments.some((s) => s === "" || s === "." || s === "..")) {
|
||||
throw new Refused(`${raw}: a path with an empty or dot segment is not taken`);
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/** What `gitea_api` may do with this call, judged on the call alone (novox/hq ADR 0266). */
|
||||
export function judgeApiCall(method: string, rawPath: string, body: unknown): ApiJudgement {
|
||||
const m = String(method || "GET").toUpperCase();
|
||||
let path: string;
|
||||
try {
|
||||
path = routedPath(rawPath);
|
||||
} catch (err) {
|
||||
return { refused: (err as Error).message };
|
||||
}
|
||||
if (READS.has(m)) {
|
||||
if (ADMIN_READS.some((r) => r.test(path))) {
|
||||
return { refused: `${m} ${path} is the forge's administration, which no tool reads (novox/hq ADR 0266)` };
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
const contents = path.match(CONTENTS);
|
||||
if (contents && ["POST", "PUT", "DELETE"].includes(m)) {
|
||||
const b = (body ?? {}) as Record<string, unknown>;
|
||||
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) } };
|
||||
}
|
||||
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 ` +
|
||||
`writes — issues, comments, labels, pull requests (never their merge), branches, and files on a branch ` +
|
||||
`that is not the default branch and that no protection rule names. The forge's administration, a ` +
|
||||
`branch's protection, a repository's settings, collaborators, teams, hooks, keys, tokens and statuses ` +
|
||||
`are the operator's at the forge, or have their own tool (gitea_merge_pull_request, gitea_commit_status, ` +
|
||||
`gitea_branch_protection_set, which only strengthens). Nothing was sent`,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a protection rule's name — a branch, or a glob of them (`*`, `**`) — covers a branch. */
|
||||
export function ruleCovers(rule: string, branch: string): boolean {
|
||||
if (rule === branch) return true;
|
||||
if (!/[*?[]/.test(rule)) return false;
|
||||
let re = "";
|
||||
for (let i = 0; i < rule.length; i++) {
|
||||
const c = rule[i];
|
||||
if (c === "*" && rule[i + 1] === "*") {
|
||||
re += ".*";
|
||||
i++;
|
||||
} else if (c === "*") re += "[^/]*";
|
||||
else if (c === "?") re += "[^/]";
|
||||
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
return new RegExp(`^${re}$`).test(branch);
|
||||
}
|
||||
|
||||
/** Refuses a file written to the default branch or to a branch a protection rule names. */
|
||||
export function judgeContentsWrite(target: string, defaultBranch: string, rules: BranchProtection[]): string | null {
|
||||
if (target === defaultBranch) {
|
||||
return `a file written to ${target}, the repository's default branch, is refused: a change reaches it by a pull ` +
|
||||
`request that passes its checks (novox/hq ADR 0266). Write it on a branch of its own (new_branch). Nothing was sent`;
|
||||
}
|
||||
const guarding = rules.map((r) => String(r.rule_name ?? r.branch_name ?? "")).filter((r) => r && ruleCovers(r, target));
|
||||
if (guarding.length) {
|
||||
return `a file written to ${target} is refused: the protection rule ${guarding.join(", ")} guards it ` +
|
||||
`(novox/hq ADR 0266). Write it on a branch of its own. Nothing was sent`;
|
||||
}
|
||||
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.
|
||||
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));
|
||||
const why: string[] = [];
|
||||
if (dropped.length) why.push(`it drops the required status ${dropped.join(", ")}`);
|
||||
if (want.push === true && before.enable_push === false) why.push("it lets a person push to the branch directly");
|
||||
if (want.blockAdminOverride === false && before.block_admin_merge_override === true) {
|
||||
why.push("it lets an administrator merge past a status that has not succeeded");
|
||||
}
|
||||
if (!why.length) return null;
|
||||
return `the protection of ${before.rule_name ?? before.branch_name} is only made stronger through a tool: ` +
|
||||
`${why.join("; ")}. Whoever may call a tool includes agents, and a branch's protection is what keeps a ` +
|
||||
`change from merging without its checks and the operator (novox/hq ADR 0266) — weakening it is the ` +
|
||||
`operator's, at the forge as themselves. Nothing was changed`;
|
||||
}
|
||||
|
||||
/** 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"]);
|
||||
|
||||
/** A repository owner's or name's shape: what keeps a name from becoming another path. */
|
||||
export function checkName(what: string, value: unknown): string {
|
||||
const v = String(value ?? "").trim();
|
||||
if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(v) || v === "." || v === "..") {
|
||||
throw new Refused(`${JSON.stringify(value)} is not a repository ${what}`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
+26
-6
@@ -34,15 +34,18 @@ export const TOKEN_NAME = "mesh-tools";
|
||||
* It sits under the `user` category despite listing repositories, not `repository`
|
||||
* — confirmed against the running forge (1.27.3), which answered
|
||||
* `required=[read:user]` to a token carrying only the other two.
|
||||
* write:admin — /admin/users: the forge's own users are the mesh's to settle, such as making
|
||||
* the builder's login a site admin so every repository the mesh may build is
|
||||
* clonable (novox/hq 229). Nothing under /orgs or write:user.
|
||||
*
|
||||
* **Not write:admin** (novox/hq ADR 0266). It was here so a person could settle the forge's users through
|
||||
* `gitea_api` (novox/hq 229); whoever may call a tool includes agents, and the forge's administration is the
|
||||
* operator's. The provisioner settles users with the admin account's own login (GiteaAdmin), never this token.
|
||||
* A token kept with a different list is minted again with this one at the next start (the scopes are kept
|
||||
* beside it), and the forge drops the old one by name — so the scope a kept token had does not outlive this.
|
||||
*
|
||||
* A token kept from before a scope was added lacks it: the forge answers such a call with
|
||||
* `403 token does not have at least one of required scope(s)`, and the client treats that like a
|
||||
* 401 — the source re-mints by name, with the whole list, and the call is retried once.
|
||||
*/
|
||||
export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user", "write:admin"];
|
||||
export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user"];
|
||||
|
||||
/** Where a client's token comes from, and what to do when the forge says it is wrong. */
|
||||
export interface TokenSource {
|
||||
@@ -153,11 +156,12 @@ export class MintedToken implements TokenSource {
|
||||
if (!this.readFile) {
|
||||
this.readFile = true;
|
||||
const kept = this.read();
|
||||
if (kept !== null) {
|
||||
if (kept !== null && this.keptScopes() === this.scopesLine()) {
|
||||
this.held = kept;
|
||||
this.log(`reusing the token kept at ${this.opts.file}`);
|
||||
return kept;
|
||||
}
|
||||
if (kept !== null) return this.mint("the kept token was minted with other scopes than the tools take now — minting one with them");
|
||||
}
|
||||
return this.mint("no token kept — minting one");
|
||||
}
|
||||
@@ -202,13 +206,29 @@ export class MintedToken implements TokenSource {
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the token at 0600, whole or not at all: a temp file beside it, then a rename. */
|
||||
/** The scopes as kept beside the token: sorted, one line. */
|
||||
private scopesLine(): string {
|
||||
return [...this.scopes].sort().join(",");
|
||||
}
|
||||
|
||||
/** The scopes the kept token was minted with, "" when not kept (a token kept before they were). */
|
||||
private keptScopes(): string {
|
||||
try {
|
||||
return readFileSync(`${this.opts.file}.scopes`, "utf8").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the token at 0600, whole or not at all: a temp file beside it, then a rename. Its scopes beside it. */
|
||||
private keep(token: string): void {
|
||||
mkdirSync(dirname(this.opts.file), { recursive: true, mode: 0o700 });
|
||||
const tmp = `${this.opts.file}.tmp`;
|
||||
writeFileSync(tmp, token + "\n", { mode: 0o600 });
|
||||
chmodSync(tmp, 0o600);
|
||||
renameSync(tmp, this.opts.file);
|
||||
writeFileSync(`${this.opts.file}.scopes.tmp`, this.scopesLine() + "\n", { mode: 0o600 });
|
||||
renameSync(`${this.opts.file}.scopes.tmp`, `${this.opts.file}.scopes`);
|
||||
}
|
||||
|
||||
private async doMint(): Promise<string> {
|
||||
|
||||
@@ -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