Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
351 changes: 351 additions & 0 deletions specs/claw/github_surface.t27
Original file line number Diff line number Diff line change
@@ -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;
}
Loading