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:
+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> {
|
||||
|
||||
Reference in New Issue
Block a user