diff --git a/modules/gitea/client.ts b/modules/gitea/client.ts index 10577d13..368dbc96 100644 --- a/modules/gitea/client.ts +++ b/modules/gitea/client.ts @@ -463,10 +463,14 @@ export class GiteaClient { return res.text(); } - async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false): Promise { + /** 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 { 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 } : {}) }), }); } diff --git a/modules/gitea/guard.ts b/modules/gitea/guard.ts new file mode 100644 index 00000000..61721b5d --- /dev/null +++ b/modules/gitea/guard.ts @@ -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; + 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; +} diff --git a/modules/gitea/test/guard.test.ts b/modules/gitea/test/guard.test.ts new file mode 100644 index 00000000..31a04804 --- /dev/null +++ b/modules/gitea/test/guard.test.ts @@ -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 => + 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((r) => server.listen(0, "127.0.0.1", () => r())); +after(() => new Promise((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) => + 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) => + 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); +}); diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index c6f3b4a7..48201041 100644 --- a/modules/gitea/test/token.test.ts +++ b/modules/gitea/test/token.test.ts @@ -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("/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 () => { diff --git a/modules/gitea/token.ts b/modules/gitea/token.ts index fb1e53a9..3aac30b1 100644 --- a/modules/gitea/token.ts +++ b/modules/gitea/token.ts @@ -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 { diff --git a/modules/gitea/tools/index.ts b/modules/gitea/tools/index.ts index 33b70d41..c2bf056a 100644 --- a/modules/gitea/tools/index.ts +++ b/modules/gitea/tools/index.ts @@ -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) => { + 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) } : {}),