diff --git a/.trinity/seals/claw_ClawTokenShape.json b/.trinity/seals/claw_ClawTokenShape.json new file mode 100644 index 0000000000..4da4f9f316 --- /dev/null +++ b/.trinity/seals/claw_ClawTokenShape.json @@ -0,0 +1,33 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+9448ab4be", + "closure": [ + { + "spec": "specs/claw/text.t27", + "spec_hash": "sha256:b65da69dff231c61a6d3a48558af9e17af42e1acbe3f4763348f1f0c2f118f21" + } + ], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:793e236c843d71659823ebee20444c902a3abf1b3f1dbda763ca42b54dc59c51", + "gen_hash_rust": "sha256:28c9289a2ea5104684dc941b3e02fa0004d5257adcd00c4593bae415f10c7df2", + "gen_hash_verilog": "sha256:7973bacc8961200f38e14563f04b61e6e9dc8b43e442fcdda85fa62dc7faed7a", + "gen_hash_zig": "sha256:30ab84bff278fbba91a98f007ba0493da30b0ef89a280c4aaffd909c0b64dd7d", + "module": "ClawTokenShape", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-18T17:57:50Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:b326da148e0d837cb05228f27efdcdf0faf22d85b0a0693a791052caf44f8b04", + "spec_path": "specs/claw/token_shape.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 10, + "total": 10, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/docs/claw/competitors.md b/docs/claw/competitors.md index 39addfaf8e..9693304b87 100644 --- a/docs/claw/competitors.md +++ b/docs/claw/competitors.md @@ -96,6 +96,16 @@ Hurting: - Copilot bundling: if free test generation ships broadly, the test-generation category compresses to $0 — position as spec/conformance infrastructure, never "another AI test tool". +- A gate that cannot judge must not block. Codacy's coverage gate waits + forever ("Waiting for coverage reports", action_required) on a pull request + with no source change, with no setting to skip it + (facioquo/stock-indicators-dotnet#2344, Oct 2026); CodeRabbit skips or fails + reviews when the installation lacks a permission (its KB, "Troubleshoot why + reviews might not trigger"). TRI CLAW had the same defect: claw-sarif was + red on every pull request for want of security_events write (#10501). The + answer is a law, not a fix per job: specs/claw/token_shape.t27 names every + job that mints an App token without continue-on-error and a step-summary + note. - The word "spec" is being colonized by prose (Spec Kit/OpenSpec); establish the sealed-hash + oracle-witness vocabulary early. - DSL adoption cost is real; the counter (specs arrive as reviewable PRs you diff --git a/specs/claw/token_shape.t27 b/specs/claw/token_shape.t27 new file mode 100644 index 0000000000..095baa1022 --- /dev/null +++ b/specs/claw/token_shape.t27 @@ -0,0 +1,353 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/claw/token_shape.t27 -- the shape every TRI CLAW job that mints an App token must have, so a +; permission the owner has not granted yet leaves a note in the step summary instead of a red check on +; every pull request. +; Recorded defect: #10501. claw-sarif asked for security_events write before it knew a .t27 changed, +; the installation lacked the permission, and the job was red on every pull request for a day (#10568 +; fixed that one job). The same shape still sits in the other App-token jobs; this law names them. +; Competitor note (docs/claw/competitors.md, 2026-10-19): Codacy's coverage gate waits forever on a +; pull request with no coverage subject and offers no setting to skip it. The rule here is the +; opposite answer to the same question: a gate with nothing to judge, or no permission to judge it, +; says so and passes. +; -- +; The rule, per job under `jobs:` in one workflow file: +; - R1 no-continue-on-error: a step that uses actions/create-github-app-token carries +; `continue-on-error: true`, on its own step line (flow form) or as a key of that step (block form). +; - R2 no-failure-note: a job with such a step also has a step whose `if:` reads `.outcome == 'failure'`, +; the step that writes the owner's fix to the step summary -- or uses the token as +; `steps.tok.outputs.token || github.token`, a fallback that keeps the job working and names who +; acted (auto-merge-ready-prs.yml, the merge line records "TRI CLAW" or "github.token"). +; A job that mints no token is not judged. +; A job that says `# token_shape: red` on one of its lines is not judged either: an operator-dispatched +; run (claw-batch) asked for one action, and a token that cannot be minted must fail it, not hide in a note. +; A text that only mentions the action inside a `run: |` block +; is not a step that uses it: only the step's own line and the step's own keys are read, by indent. +; -- +; Input: the workflow file's bytes. Output: one line per violation, "\t\n", in file order; +; token_shape_report returns the bytes it needs (a write past cap is dropped, the cursor stays honest) +; and token_shape_count the number of violations. +; phi^2 + 1/phi^2 = 3 | TRINITY + +module ClawTokenShape; + +use claw::text; + +pub const RULE_COE : str = "no-continue-on-error"; +pub const RULE_COE_LEN : usize = 20; +pub const RULE_NOTE : str = "no-failure-note"; +pub const RULE_NOTE_LEN : usize = 15; + +const MINT : str = "uses: actions/create-github-app-token@"; +const MINT_LEN : usize = 38; +const COE : str = "continue-on-error: true"; +const COE_LEN : usize = 23; +const NOTE : str = ".outcome == 'failure'"; +const NOTE_LEN : usize = 21; +const FALLBACK : str = "outputs.token ||"; +const FALLBACK_LEN : usize = 16; +const OPT_OUT : str = "# token_shape: red"; +const OPT_OUT_LEN : usize = 18; + +/// Does text[s..e) contain needle anywhere? +fn span_has(text : []const u8, s : usize, e : usize, needle : str, nlen : usize) -> bool { + var i : usize = s; + while (i + nlen <= e) { + var k : usize = 0; + var diff : usize = 0; + while (k < nlen) { + if (text[i + k] != needle[k]) { diff += 1; } + k += 1; + } + if (diff == 0) { return true; } + i += 1; + } + return false; +} + +/// Does text[s..e) start with p? +fn span_starts(text : []const u8, s : usize, e : usize, p : str, plen : usize) -> bool { + if (s + plen > e) { return false; } + var k : usize = 0; + while (k < plen) { + if (text[s + k] != p[k]) { return false; } + k += 1; + } + return true; +} + +/// End of the line that starts at s: the index of its newline, or n. +fn line_end(text : []const u8, n : usize, s : usize) -> usize { + var i : usize = s; + while (i < n) { + if (text[i] == '\n') { return i; } + i += 1; + } + return n; +} + +/// Leading spaces of the line text[s..e). +fn indent_of(text : []const u8, s : usize, e : usize) -> usize { + var i : usize = s; + while (i < e) { + if (text[i] != ' ') { return i - s; } + i += 1; + } + return e - s; +} + +/// Is text[s..e) a job header: two spaces, a name with no space, a colon, then nothing but spaces? +fn is_job_header(text : []const u8, s : usize, e : usize) -> bool { + if (indent_of(text, s, e) != 2) { return false; } + var i : usize = s + 2; + if (i >= e) { return false; } + if (text[i] == '#' or text[i] == '-') { return false; } + while (i < e) { + if (text[i] == ':') { + var j : usize = i + 1; + while (j < e) { + if (text[j] != ' ' and text[j] != 13) { return false; } + j += 1; + } + return i > s + 2; + } + if (text[i] == ' ') { return false; } + i += 1; + } + return false; +} + +/// Is text[s..e) a list item, `- ` after its indent? +fn is_item(text : []const u8, s : usize, e : usize) -> bool { + const p : usize = s + indent_of(text, s, e); + return span_starts(text, p, e, "- ", 2); +} + +/// Copy text[s..e) to dst at w, clipped at cap. +fn put_span(dst : []u8, cap : usize, w : usize, text : []const u8, s : usize, e : usize) -> usize { + var i : usize = s; + var x : usize = w; + while (i < e) { + if (x < cap) { dst[x] = text[i]; } + x += 1; + i += 1; + } + return x; +} + +/// One violation line "\t\n". +fn put_violation(dst : []u8, cap : usize, w : usize, text : []const u8, js : usize, je : usize, rule : str, rlen : usize) -> usize { + var x : usize = put_span(dst, cap, w, text, js, je); + x = put_str(dst, cap, x, "\t", 1); + x = put_str(dst, cap, x, rule, rlen); + return put_str(dst, cap, x, "\n", 1); +} + +/// The report: one line per violation, in file order; returns the bytes it needs. +pub fn token_shape_report(text : []const u8, n : usize, dst : []u8, cap : usize) -> usize { + var w : usize = 0; + var in_jobs : bool = false; + var have_job : bool = false; + var js : usize = 0; + var je : usize = 0; + var step_indent : usize = 0; + var mints : bool = false; + var bad_coe : bool = false; + var noted : bool = false; + var red_ok : bool = false; + var in_step : bool = false; + var step_mints : bool = false; + var step_coe : bool = false; + var s : usize = 0; + while (s <= n) { + const e : usize = line_end(text, n, s); + const ind : usize = indent_of(text, s, e); + const header : bool = in_jobs and is_job_header(text, s, e); + const top : bool = s < n and ind == 0 and e > s and text[s] != '#'; + const item : bool = have_job and is_item(text, s, e) and ind > 2 and (step_indent == 0 or ind == step_indent); + // a step ends at the next step, the next job, the end of `jobs:` or the end of the file + if (in_step and (item or header or top or s >= n)) { + if (step_mints and !step_coe) { bad_coe = true; } + if (step_mints) { mints = true; } + in_step = false; + } + // a job ends at the next job, the end of `jobs:` or the end of the file + if (have_job and (header or top or s >= n)) { + if (red_ok) { mints = false; } + if (mints and bad_coe) { w = put_violation(dst, cap, w, text, js, je, RULE_COE, RULE_COE_LEN); } + if (mints and !noted) { w = put_violation(dst, cap, w, text, js, je, RULE_NOTE, RULE_NOTE_LEN); } + have_job = false; + } + if (s >= n) { return w; } + if (top) { in_jobs = span_starts(text, s, e, "jobs:", 5); } + if (header) { + have_job = true; + js = s + 2; + je = js; + while (text[je] != ':') { je += 1; } + step_indent = 0; + mints = false; + bad_coe = false; + noted = false; + red_ok = false; + } else if (have_job) { + if (span_has(text, s, e, NOTE, NOTE_LEN)) { noted = true; } + if (span_has(text, s, e, FALLBACK, FALLBACK_LEN)) { noted = true; } + if (span_has(text, s, e, OPT_OUT, OPT_OUT_LEN)) { red_ok = true; } + if (item) { + if (step_indent == 0) { step_indent = ind; } + in_step = true; + step_mints = span_has(text, s, e, MINT, MINT_LEN); + step_coe = span_has(text, s, e, COE, COE_LEN); + } else if (in_step and ind == step_indent + 2) { + if (span_starts(text, s + ind, e, MINT, MINT_LEN)) { step_mints = true; } + if (span_starts(text, s + ind, e, COE, COE_LEN)) { step_coe = true; } + } + } + s = e + 1; + } + return w; +} + +/// How many violations the report holds: its newlines. +pub fn token_shape_count(text : []const u8, n : usize) -> u32 { + var out : [4096]u8 = undefined; + const m : usize = token_shape_report(text, n, &out, 4096); + var c : u32 = 0; + var i : usize = 0; + while (i < m and i < 4096) { + if (out[i] == '\n') { c += 1; } + i += 1; + } + return c; +} + +test the_shape_10568_gave_claw_sarif_passes { + var t : [512]u8 = undefined; + var n : usize = put_str(&t, 512, 0, "jobs:\n", 6); + n = put_str(&t, 512, n, " claw-a:\n", 10); + n = put_str(&t, 512, n, " steps:\n", 11); + n = put_str(&t, 512, n, " - id: have\n", 17); + n = put_str(&t, 512, n, " run: echo ok=1\n", 23); + n = put_str(&t, 512, n, " - {id: tok, continue-on-error: true, uses: actions/create-github-app-token@v2}\n", 85); + n = put_str(&t, 512, n, " - {if: steps.tok.outcome == 'failure', run: echo note}\n", 61); + assert n == 213; + assert token_shape_count(&t, n) == 0; +} + +test the_shape_claw_sarif_had_before_10568_breaks_both_rules { + var t : [512]u8 = undefined; + var n : usize = put_str(&t, 512, 0, "jobs:\n", 6); + n = put_str(&t, 512, n, " claw-a:\n", 10); + n = put_str(&t, 512, n, " steps:\n", 11); + n = put_str(&t, 512, n, " - id: have\n", 17); + n = put_str(&t, 512, n, " run: echo ok=1\n", 23); + n = put_str(&t, 512, n, " - {id: tok, uses: actions/create-github-app-token@v2}\n", 60); + assert n == 127; + var out : [256]u8 = undefined; + const m : usize = token_shape_report(&t, n, &out, 256); + assert m == 51; + assert buf_has(&out, m, "claw-a\tno-continue-on-error\n", 28) == true; + assert buf_has(&out, m, "claw-a\tno-failure-note\n", 23) == true; + assert token_shape_count(&t, n) == 2; +} + +test continue_on_error_as_a_key_of_a_block_step_counts { + var t : [512]u8 = undefined; + var n : usize = put_str(&t, 512, 0, "jobs:\n", 6); + n = put_str(&t, 512, n, " claw-a:\n", 10); + n = put_str(&t, 512, n, " steps:\n", 11); + n = put_str(&t, 512, n, " - id: tok\n", 16); + n = put_str(&t, 512, n, " uses: actions/create-github-app-token@v2\n", 49); + n = put_str(&t, 512, n, " continue-on-error: true\n", 32); + n = put_str(&t, 512, n, " - {if: steps.tok.outcome == 'failure', run: echo note}\n", 61); + assert n == 185; + assert token_shape_count(&t, n) == 0; +} + +test continue_on_error_on_a_later_step_does_not_cover_the_token_step { + var t : [512]u8 = undefined; + var n : usize = put_str(&t, 512, 0, "jobs:\n", 6); + n = put_str(&t, 512, n, " claw-a:\n", 10); + n = put_str(&t, 512, n, " steps:\n", 11); + n = put_str(&t, 512, n, " - id: tok\n", 16); + n = put_str(&t, 512, n, " uses: actions/create-github-app-token@v2\n", 49); + n = put_str(&t, 512, n, " - id: have\n", 17); + n = put_str(&t, 512, n, " continue-on-error: true\n", 32); + n = put_str(&t, 512, n, " - {if: steps.tok.outcome == 'failure', run: echo note}\n", 61); + assert n == 202; + var out : [256]u8 = undefined; + const m : usize = token_shape_report(&t, n, &out, 256); + assert m == 28; + assert buf_has(&out, m, "claw-a\tno-continue-on-error\n", 28) == true; +} + +test a_job_that_mints_no_token_is_not_judged { + var t : [256]u8 = undefined; + var n : usize = put_str(&t, 256, 0, "jobs:\n", 6); + n = put_str(&t, 256, n, " plain:\n", 9); + n = put_str(&t, 256, n, " steps:\n", 11); + n = put_str(&t, 256, n, " - id: have\n", 17); + n = put_str(&t, 256, n, " run: echo ok=1\n", 23); + assert n == 66; + assert token_shape_count(&t, n) == 0; +} + +test the_action_named_inside_a_run_block_is_not_a_token_step { + var t : [256]u8 = undefined; + var n : usize = put_str(&t, 256, 0, "jobs:\n", 6); + n = put_str(&t, 256, n, " plain:\n", 9); + n = put_str(&t, 256, n, " steps:\n", 11); + n = put_str(&t, 256, n, " - run: |\n", 15); + n = put_str(&t, 256, n, " - {id: x, uses: actions/create-github-app-token@v2}\n", 62); + assert n == 103; + assert token_shape_count(&t, n) == 0; +} + +test each_job_is_judged_alone { + var t : [512]u8 = undefined; + var n : usize = put_str(&t, 512, 0, "jobs:\n", 6); + n = put_str(&t, 512, n, " claw-a:\n", 10); + n = put_str(&t, 512, n, " steps:\n", 11); + n = put_str(&t, 512, n, " - {id: tok, uses: actions/create-github-app-token@v2}\n", 60); + n = put_str(&t, 512, n, " plain:\n", 9); + n = put_str(&t, 512, n, " steps:\n", 11); + n = put_str(&t, 512, n, " - {if: steps.tok.outcome == 'failure', run: echo note}\n", 61); + assert n == 168; + var out : [256]u8 = undefined; + const m : usize = token_shape_report(&t, n, &out, 256); + assert m == 51; + assert token_shape_count(&t, n) == 2; +} + +test a_clipped_report_still_returns_the_bytes_it_needs { + var t : [256]u8 = undefined; + var n : usize = put_str(&t, 256, 0, "jobs:\n", 6); + n = put_str(&t, 256, n, " claw-a:\n", 10); + n = put_str(&t, 256, n, " steps:\n", 11); + n = put_str(&t, 256, n, " - {id: tok, uses: actions/create-github-app-token@v2}\n", 60); + var out : [8]u8 = undefined; + assert token_shape_report(&t, n, &out, 8) == 51; + assert out[0] == 'c'; + assert out[7] == 'n'; +} + +test a_token_with_a_github_token_fallback_needs_no_note { + var t : [512]u8 = undefined; + var n : usize = put_str(&t, 512, 0, "jobs:\n", 6); + n = put_str(&t, 512, n, " claw-a:\n", 10); + n = put_str(&t, 512, n, " steps:\n", 11); + n = put_str(&t, 512, n, " - {id: tok, continue-on-error: true, uses: actions/create-github-app-token@v2}\n", 85); + n = put_str(&t, 512, n, " - {env: {T: steps.tok.outputs.token || github.token}, run: echo x}\n", 73); + assert n == 185; + assert token_shape_count(&t, n) == 0; +} + +test a_job_that_says_red_on_purpose_is_not_judged { + var t : [256]u8 = undefined; + var n : usize = put_str(&t, 256, 0, "jobs:\n", 6); + n = put_str(&t, 256, n, " claw-b:\n", 10); + n = put_str(&t, 256, n, " steps:\n", 11); + n = put_str(&t, 256, n, " - {id: tok, uses: actions/create-github-app-token@v2} # token_shape: red - an operator asked\n", 100); + assert n == 127; + assert token_shape_count(&t, n) == 0; +}