diff --git a/specs/claw/github_surface.t27 b/specs/claw/github_surface.t27 new file mode 100644 index 0000000000..5147e38c76 --- /dev/null +++ b/specs/claw/github_surface.t27 @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/claw/github_surface.t27 -- what the TRI CLAW GitHub App does with each GitHub API surface, +; for .t27, and the token each job is allowed to mint (epic #9271). +; Owner, 2026-10-18, translated: "I gave full access. Study the GitHub API and connect full +; coverage and value for .t27." Measured the same day (GET /apps/tri-claw, updated_at +; 2026-10-18T06:59:05Z): the App holds write on 40 repository permissions, all repositories of +; the gHashTag user account (installation 167308749). +; -- +; Full access is the grant, not the token. The private key lives in two places (the t27 Actions +; secret CLAW_APP_PRIVATE_KEY and the reviewer bees' Keychain), so a token minted without a +; scope from any t27 branch would carry administration, secrets and workflows write over every +; repository. The law here: a job names the SURFACE it serves, the token carries exactly that +; row's permission at that row's level (plus metadata read, which GitHub always adds), and a +; surface that is not mintable mints nothing. The grant may stay wide; every token stays narrow. +; -- +; Shape (same discipline as specs/claw/recipes.t27): one ROW PER SURFACE, parallel arrays, +; alignment pinned by tests. STATUS decides whether a row ever reaches a token: +; - S_LIVE / S_PLANNED: mintable. PLANNED rows are filed lanes; a row moves to LIVE in the PR +; that ships it. +; - S_OWNER: mintable only on the owner's explicit yes for that run (repository rulesets bind +; every merge in a repository; that is the owner's call, never the loop's). +; - S_ACTIONS: the surface is real but the App adds nothing -- GitHub Actions' own token or OIDC +; does it better (attestation signing needs the Actions OIDC token, not an App key). +; - S_NEEDS_HOOK: the value arrives only as a webhook event; claw runs zero servers +; (specs/claw/service.t27), so these rows wait for a receiver and mint nothing until then. +; - S_UNAVAILABLE: the account type blocks it (custom properties exist only on organizations; +; gHashTag is a user account). +; - S_REFUSED: no .t27 value, and holding it widens what a leaked token can do. +; The honest limit: this table records what each surface is FOR. It does not prove a workflow +; passes the scope it names -- that is the job of the workflow's own reviewer, and of the lane +; that ships each PLANNED row. +; ASCII only (L3). phi^2 + 1/phi^2 = 3 | TRINITY + +module ClawGithubSurface; + +use claw::recipes; + +/// Row status. +pub const S_PLANNED : u8 = 0; +pub const S_LIVE : u8 = 1; +pub const S_OWNER : u8 = 2; +pub const S_ACTIONS : u8 = 3; +pub const S_NEEDS_HOOK : u8 = 4; +pub const S_UNAVAILABLE : u8 = 5; +pub const S_REFUSED : u8 = 6; + +/// Permission level, as GitHub spells it: none < read < write. +pub const L_NONE : u8 = 0; +pub const L_READ : u8 = 1; +pub const L_WRITE : u8 = 2; + +/// The App's id; a ruleset's required check pins integration_id to it, so no other actor can +/// post a green claw/t27f. +pub const APP_ID : u32 = 5166765; + +/// Row count of the table. +pub const COUNT : u32 = 22; + +/// Surface slug; the lookup key for every column. +pub const SURFACES : [22]str = [ + "check-run", "check-annotations", "code-scanning-sarif", "pr-files", "pr-suggestions", + "spec-read", "autofix-commit", "cross-repo-dispatch", "t27f-preview", "findings-issues", + "dependency-snapshot", "required-check-ruleset", "attestations", "commit-statuses", + "check-run-buttons", "merge-queue", "custom-properties", "secrets", "workflows", + "administration-other", "codespaces-agents", "repo-creation-hooks", +]; + +/// The GitHub App permission the surface needs (GitHub's own key names). +pub const PERMS : [22]str = [ + "checks", "checks", "security_events", "pull_requests", "pull_requests", + "contents", "contents", "contents", "deployments", "issues", + "contents", "administration", "attestations", "statuses", + "checks", "merge_queues", "repository_custom_properties", "secrets", "workflows", + "administration", "codespaces", "repository_hooks", +]; + +/// The level the surface needs. +pub const LEVELS : [22]u8 = [ + L_WRITE, L_WRITE, L_WRITE, L_READ, L_WRITE, + L_READ, L_WRITE, L_WRITE, L_WRITE, L_WRITE, + L_WRITE, L_WRITE, L_WRITE, L_WRITE, + L_WRITE, L_READ, L_WRITE, L_WRITE, L_WRITE, + L_WRITE, L_WRITE, L_WRITE, +]; + +pub const STATUS : [22]u8 = [ + S_PLANNED, S_PLANNED, S_PLANNED, S_PLANNED, S_PLANNED, + S_PLANNED, S_PLANNED, S_PLANNED, S_PLANNED, S_PLANNED, + S_PLANNED, S_OWNER, S_ACTIONS, S_REFUSED, + S_NEEDS_HOOK, S_NEEDS_HOOK, S_UNAVAILABLE, S_REFUSED, S_REFUSED, + S_REFUSED, S_REFUSED, S_REFUSED, +]; + +/// The REST endpoint (or webhook event) the surface is. +pub const ENDPOINTS : [22]str = [ + "POST /repos/{o}/{r}/check-runs", + "PATCH /repos/{o}/{r}/check-runs/{id}", + "POST /repos/{o}/{r}/code-scanning/sarifs", + "GET /repos/{o}/{r}/pulls/{n}/files", + "POST /repos/{o}/{r}/pulls/{n}/reviews", + "GET /repos/{o}/{r}/git/trees/{sha}", + "POST /repos/{o}/{r}/git/commits", + "POST /repos/{o}/{r}/dispatches", + "POST /repos/{o}/{r}/deployments", + "POST /repos/{o}/{r}/issues/{n}/sub_issues", + "POST /repos/{o}/{r}/dependency-graph/snapshots", + "POST /repos/{o}/{r}/rulesets", + "POST /repos/{o}/{r}/attestations", + "POST /repos/{o}/{r}/statuses/{sha}", + "event check_run.requested_action", + "event merge_group.checks_requested", + "PATCH /repos/{o}/{r}/properties/values", + "PUT /repos/{o}/{r}/actions/secrets/{name}", + "PUT /repos/{o}/{r}/contents/.github/workflows/{f}", + "PATCH /repos/{o}/{r}", + "POST /repos/{o}/{r}/codespaces", + "POST /repos/{o}/{r}/hooks", +]; + +/// What the surface is worth to .t27 -- or why it is not used. +pub const VALUES : [22]str = [ + "the claw/t27f verdict on the PR head, under the App's own identity", + "t27c diagnostics pinned to the .t27 line in the diff, at most 50 per call", + "vacuous tests and dead specs as alerts that persist across commits and close when fixed", + "which .t27 files a PR touches, so only those specs are checked", + "t27c fmt drift as one-click suggestion blocks; review event COMMENT, never APPROVE", + "the specs and seals at the PR head, read without a clone", + "regenerated gen/ and reseals as a bot commit; unlike GITHUB_TOKEN, an App push re-runs CI", + "a spec merge in t27 tells trinity to re-vendor the cards it copies", + "a View deployment button from the PR to the T27F diagram of its specs", + "per-spec findings filed as sub-issues of one epic, closed when the finding clears", + "a repository that vendors t27 specs shows t27 in its dependency graph", + "claw/t27f becomes required, pinned to the App id so nobody else can post its green", + "signing needs the Actions OIDC token; t27o-release already attests, the App adds nothing", + "a second, weaker channel for what check-run already says", + "Regenerate and Reseal buttons on the check; the event arrives only by webhook", + "a queued merge needs claw/t27f on the queue sha in minutes; a 10-minute poll stalls it", + "t27 coverage as a repository property; properties exist only on organization accounts", + "no .t27 value; a token holding it can plant a secret in any repository", + "workflow files are owner gate files under the own-language gate; a bot never writes them", + "settings, collaborators, deletion; only the ruleset row uses administration", + "no .t27 value", + "no .t27 value; widens what a leaked token can create or redirect", +]; + +/// The measured grant (GET /apps/tri-claw, 2026-10-18T06:59:05Z), for the permissions this +/// table names. Re-measure and edit here when the owner changes the App. +pub const GRANT_PERMS : [15]str = [ + "checks", "security_events", "pull_requests", "contents", "deployments", + "issues", "administration", "attestations", "statuses", "merge_queues", + "repository_custom_properties", "secrets", "workflows", "codespaces", "repository_hooks", +]; + +pub const GRANT_LEVELS : [15]u8 = [ + L_WRITE, L_WRITE, L_WRITE, L_WRITE, L_WRITE, + L_WRITE, L_WRITE, L_WRITE, L_WRITE, L_WRITE, + L_WRITE, L_WRITE, L_WRITE, L_WRITE, L_WRITE, +]; + +/// Row index of a surface; -1 when unknown. Fail closed: callers check before use. +pub fn row_of(surface : str) -> i32 { + for i in 0..22 { + if recipes::str_eq(SURFACES[i], surface) { return (i) as i32; } + } + return -1; +} + +/// Whether row i may ever reach a token. owner_yes is the owner's explicit yes for this run. +pub fn row_mintable(i : usize, owner_yes : bool) -> bool { + const s = STATUS[i]; + if (s == S_LIVE or s == S_PLANNED) { return true; } + if (s == S_OWNER and owner_yes) { return true; } + return false; +} + +/// The permission a job serving `surface` mints; "" when unknown or not mintable. +pub fn perm_of(surface : str, owner_yes : bool) -> str { + const r = row_of(surface); + if (r < 0) { return ""; } + if (row_mintable((r) as usize, owner_yes) == false) { return ""; } + return PERMS[(r) as usize]; +} + +/// The level a job serving `surface` mints; L_NONE when unknown or not mintable. +pub fn level_of(surface : str, owner_yes : bool) -> u8 { + const r = row_of(surface); + if (r < 0) { return L_NONE; } + if (row_mintable((r) as usize, owner_yes) == false) { return L_NONE; } + return LEVELS[(r) as usize]; +} + +/// The highest level any mintable row asks for `perm`; L_NONE means no token ever carries it. +pub fn mint_ceiling(perm : str, owner_yes : bool) -> u8 { + var best : u8 = L_NONE; + for i in 0..22 { + if (recipes::str_eq(PERMS[i], perm) and row_mintable(i, owner_yes)) { + if (LEVELS[i] > best) { best = LEVELS[i]; } + } + } + return best; +} + +/// The level the App was measured to hold for `perm`; L_NONE when not granted. +pub fn granted(perm : str) -> u8 { + for i in 0..15 { + if recipes::str_eq(GRANT_PERMS[i], perm) { return GRANT_LEVELS[i]; } + } + return L_NONE; +} + +/// Every mintable row fits inside the measured grant: the App can actually mint what it plans. +pub fn plan_fits_grant(owner_yes : bool) -> bool { + for i in 0..22 { + if (row_mintable(i, owner_yes)) { + if (LEVELS[i] > granted(PERMS[i])) { return false; } + } + } + return true; +} + +/// How many granted permissions no token will ever use at their granted level -- the part of +/// the grant that only widens a leak. The owner may narrow the App by exactly these. +pub fn over_granted(owner_yes : bool) -> u32 { + var n : u32 = 0; + for i in 0..15 { + if (mint_ceiling(GRANT_PERMS[i], owner_yes) < GRANT_LEVELS[i]) { n += 1; } + } + return n; +} + +/// Census by status. +pub fn count_status(s : u8) -> u32 { + var n : u32 = 0; + for i in 0..22 { + if STATUS[i] == s { n += 1; } + } + return n; +} + +/// Every row has surface, permission, endpoint and value. +pub fn every_row_filled() -> bool { + for i in 0..22 { + if SURFACES[i] == "" { return false; } + if PERMS[i] == "" { return false; } + if ENDPOINTS[i] == "" { return false; } + if VALUES[i] == "" { return false; } + } + return true; +} + +/// No surface appears twice among the first n rows. +pub fn all_surfaces_unique(n : usize) -> bool { + var i : usize = 0; + while (i < n) { + var j : usize = i + 1; + while (j < n) { + if recipes::str_eq(SURFACES[i], SURFACES[j]) { return false; } + j += 1; + } + i += 1; + } + return true; +} + +test surface_rows_are_filled_and_unique { + assert every_row_filled() == true; + assert all_surfaces_unique((COUNT) as usize) == true; +} + +test surface_census_adds_up { + assert count_status(S_PLANNED) == 11; + assert count_status(S_LIVE) == 0; + assert count_status(S_OWNER) == 1; + assert count_status(S_ACTIONS) == 1; + assert count_status(S_NEEDS_HOOK) == 2; + assert count_status(S_UNAVAILABLE) == 1; + assert count_status(S_REFUSED) == 6; + var total : u32 = 0; + var s : u8 = 0; + while (s <= S_REFUSED) { + total += count_status(s); + s += 1; + } + assert total == COUNT; +} + +test claw_t27f_job_mints_exactly_three_narrow_scopes { + // The rollout's claw/t27f job serves check-run, pr-files and spec-read. + assert recipes::str_eq(perm_of("check-run", false), "checks") == true; + assert level_of("check-run", false) == L_WRITE; + assert recipes::str_eq(perm_of("pr-files", false), "pull_requests") == true; + assert level_of("pr-files", false) == L_READ; + assert recipes::str_eq(perm_of("spec-read", false), "contents") == true; + assert level_of("spec-read", false) == L_READ; +} + +test refused_permissions_never_reach_a_token { + // Even with the owner's yes: refusal is about value, not consent. + assert mint_ceiling("secrets", true) == L_NONE; + assert mint_ceiling("workflows", true) == L_NONE; + assert mint_ceiling("statuses", true) == L_NONE; + assert mint_ceiling("codespaces", true) == L_NONE; + assert mint_ceiling("repository_hooks", true) == L_NONE; + assert level_of("secrets", true) == L_NONE; + assert level_of("administration-other", true) == L_NONE; +} + +test administration_only_with_the_owner_yes { + assert mint_ceiling("administration", false) == L_NONE; + assert level_of("required-check-ruleset", false) == L_NONE; + assert mint_ceiling("administration", true) == L_WRITE; + assert level_of("required-check-ruleset", true) == L_WRITE; + // The yes opens the ruleset row, never the other administration row. + assert level_of("administration-other", true) == L_NONE; +} + +test hook_rows_mint_nothing_although_their_permission_is_minted_elsewhere { + // checks is write through check-run; the buttons row still mints nothing on its own. + assert mint_ceiling("checks", false) == L_WRITE; + assert level_of("check-run-buttons", false) == L_NONE; + assert mint_ceiling("merge_queues", true) == L_NONE; + assert level_of("custom-properties", true) == L_NONE; + assert level_of("attestations", true) == L_NONE; +} + +test write_surfaces_that_carry_the_value { + assert mint_ceiling("security_events", false) == L_WRITE; + assert mint_ceiling("pull_requests", false) == L_WRITE; + assert mint_ceiling("contents", false) == L_WRITE; + assert mint_ceiling("deployments", false) == L_WRITE; + assert mint_ceiling("issues", false) == L_WRITE; +} + +test unknown_surface_fails_closed { + assert row_of("no-such-surface") == -1; + assert recipes::str_eq(perm_of("no-such-surface", true), "") == true; + assert level_of("no-such-surface", true) == L_NONE; + assert granted("no_such_permission") == L_NONE; +} + +test the_plan_fits_the_measured_grant { + assert plan_fits_grant(false) == true; + assert plan_fits_grant(true) == true; +} + +test over_grant_is_counted_so_the_owner_can_narrow_it { + // statuses, attestations, merge_queues, repository_custom_properties, secrets, workflows, + // codespaces, repository_hooks -- and administration until the owner says yes. + assert over_granted(false) == 9; + assert over_granted(true) == 8; +}