Repository navigation
993 lines (955 loc) · 50.5 KB
/
Copy pathcursor-axis-base.yml
File metadata and controls
993 lines (955 loc) · 50.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
name: Cursor axis (base)
# Shared body of every cursor-approve axis workflow (axis-correctness.yml,
# axis-conformance.yml, ...). Not called by consumers directly: each axis is a
# thin wrapper that declares ONLY the secrets that axis needs, so an axis can
# never see another axis's credentials. See docs/callers/cursor-approve.md.
#
# Inputs:
# axis one of business/design/correctness/completeness/conformance
# commit_sha the PR head the axis judges (the commit cursor-review reviewed)
# model Cursor model id (default: cursor-review's judge model)
# runs_on JSON-encoded runs-on, parsed with fromJSON like cursor-review's
# checkout true → full read-only checkout of the PR head at commit_sha
# context_sources comma list of linear,notion,slack: the context-proxy.py
# tools this axis gets (business/design/completeness only). Empty
# → no MCP server at all.
# no_shell true → deny cursor-agent's shell and file-write tools, and fail
# the job if the transcript shows one was called anyway. Required
# (with checkout false) for business and design. false → the
# shell is ALLOWED (an explicit Shell(*) rule), and the job fails
# if every shell call in the transcript was rejected.
# prior_rounds 0..3 (default 0 = off): above 0, download this run's
# `prior-axes` artifact (cursor-approve.yml's start phase) and
# append this axis's own earlier verdicts to the prompt as one
# fenced DATA block. A missing artifact, or no entry for this
# axis, appends nothing.
# axis_direct_model OPT-IN (default '' = the Cursor CLI, unchanged): an
# Anthropic model id (e.g. `claude-opus-5-5`) that runs this axis
# through the Anthropic API directly, via the pinned Claude Code
# CLI (`.github/cursor-review/package.json`), exactly as
# cursor-review's direct cells and direct judge do, in its OWN
# job (`axis-direct`) on GitHub-hosted `ubuntu-latest` — never
# `runs_on` — the Cursor job skipped. Checkout axes only. Same prompt
# plus prompt-direct-runner.md (no shell: the workflow writes the
# merge-base diff into the checkout instead), tools Read, Grep and
# Glob only, `--restricted` + deny rules on /proc and /sys,
# `--setting-sources ""`, never `--bare` (it drops Grep and Glob).
# Same `axis-<axis>` verdict artifact; a failure or timeout uploads
# no verdict, exactly like a Cursor failure, and there is NO
# fallback to Cursor. Also uploads `usage-direct-axis-<axis>-<model>`
# (the cells' token-usage record, kept 30 days). Not combinable
# with context_sources.
# Secrets:
# CURSOR_API_KEY
# ANTHROPIC_API_KEY required only with axis_direct_model; referenced by the
# `axis-direct` job alone (its key check, run and verdict-extract
# steps, in their env), never by the Cursor job, never on disk.
# LINEAR_KEY, NOTION_TOKEN, SLACK_TOKEN optional, read-only bot tokens for
# the context proxy. They are written to a 0600 file in their own
# step; the proxy reads and deletes it BEFORE the agent starts, so
# no token is ever in the agent step's env or reachable by it.
#
# Without a checkout (business, design) the workflow, not the agent, fetches the
# change from the GitHub API at commit_sha's merge base: business gets the
# changed-file list with line counts, design the diff truncated to 200 KB.
#
# Output: one artifact, `axis-<axis>`, holding `<axis>.json` — exactly one JSON
# object `{"verdict", "confidence", "headline", "summary", "commit_sha"}`
# (commit_sha is stamped from this job's input, not taken from the model).
# `verdict` is red/yellow/green/n-a; `headline` is the one sentence the PR
# card's table shows, so it is REQUIRED and capped at 240 characters, while
# `summary` is the detail behind it, collapsed under the card's table and in
# the run's job summary. A failing run uploads nothing,
# which cursor-approve.yml's decide phase reads as "no result" and never approves.
# The agent's full stream-json transcript (plus the proxy's call log) goes to a
# SEPARATE artifact, `transcript-axis-<axis>`, named so decide's `axis-*` download
# never matches it.
#
# The scripts and prompts load from THIS repo at `job.workflow_sha` — the
# commit the caller's `uses:` resolved to, computed by the runner — so a PR can
# never rewrite the prompt judging it, and no caller-supplied ref is needed.
on:
workflow_call:
inputs:
axis:
type: string
required: true
commit_sha:
type: string
required: true
model:
type: string
default: claude-opus-5-5-xhigh
runs_on:
type: string
default: '"ubuntu-latest"'
checkout:
type: boolean
default: false
context_sources:
description: Comma list of linear,notion,slack context-proxy tools; empty for none.
type: string
default: ''
no_shell:
description: Deny cursor-agent's shell and file-write tools.
type: boolean
default: false
prior_rounds:
description: 0..3 earlier rounds of this axis's own verdicts to show it; 0 = off.
type: number
default: 0
axis_direct_model:
description: Anthropic model id to run this axis on the direct API instead of Cursor; empty = Cursor.
type: string
default: ''
secrets:
CURSOR_API_KEY:
required: true
ANTHROPIC_API_KEY:
required: false
LINEAR_KEY:
required: false
NOTION_TOKEN:
required: false
SLACK_TOKEN:
required: false
permissions:
contents: read
env:
# Same pin as cursor-review.yml's (a test keeps them equal); consumed by
# .github/cursor-review/install-cursor-cli.sh.
CURSOR_CLI_VERSION: 2026.10.01-e373342
CURSOR_CLI_SHA256: a79726c6e644520e993970be4c45775a6889802b67abe461a677a53219ae28e8
CURSOR_REVIEW_ASSETS: ${{ github.workspace }}/_cursor_approve_assets/.github/cursor-review
CURSOR_APPROVE_ASSETS: ${{ github.workspace }}/_cursor_approve_assets/.github/cursor-approve
jobs:
axis:
name: 'axis: ${{ inputs.axis }}'
# The default runner. The direct runner is the `axis-direct` job below,
# so this job never references ANTHROPIC_API_KEY.
if: inputs.axis_direct_model == ''
runs-on: ${{ fromJSON(inputs.runs_on || '"ubuntu-latest"') }}
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Check inputs
env:
AXIS: ${{ inputs.axis }}
COMMIT_SHA: ${{ inputs.commit_sha }}
MODEL: ${{ inputs.model }}
CONTEXT_SOURCES: ${{ inputs.context_sources }}
CHECKOUT: ${{ inputs.checkout }}
NO_SHELL: ${{ inputs.no_shell }}
PRIOR_ROUNDS: ${{ inputs.prior_rounds }}
REPO_PRIVATE: ${{ github.event.repository.private }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
BASE_REPO: ${{ github.repository }}
WORKFLOW_SHA: ${{ job.workflow_sha }}
run: |
case "$AXIS" in
business|design|correctness|completeness|conformance) ;;
*) echo "::error::unknown axis '$AXIS'"; exit 1 ;;
esac
# A `type: number` can render as `1.0`.
case "$PRIOR_ROUNDS" in
0|1|2|3|0.0|1.0|2.0|3.0) ;;
*) echo "::error::prior_rounds must be an integer from 0 to 3, got '$PRIOR_ROUNDS'"; exit 1 ;;
esac
if ! printf '%s' "$COMMIT_SHA" | grep -Eq '^[0-9a-f]{40}$'; then
echo "::error::commit_sha must be a full 40-hex SHA"; exit 1
fi
if ! printf '%s' "$MODEL" | grep -Eq '^[a-z0-9][a-z0-9._-]*$'; then
echo "::error::model must be a Cursor model id"; exit 1
fi
echo "Runner: Cursor CLI, \`$MODEL\`" >> "$GITHUB_STEP_SUMMARY"
# [[ =~ ]] anchors the whole value; grep's ^...$ would anchor one line.
if ! [[ "$CONTEXT_SOURCES" =~ ^((linear|notion|slack)(,(linear|notion|slack))*)?$ ]]; then
echo "::error::context_sources must be a comma list of linear, notion, slack"; exit 1
fi
# No axis reads Slack (docs/callers/cursor-approve.md, "Context
# axes"): enforced here, not only in the wrappers, so a direct caller
# of this public base cannot turn it back on.
case ",$CONTEXT_SOURCES," in
*,slack,*) echo "::error::context_sources may not include slack: no axis reads Slack"; exit 1 ;;
esac
if [ -n "$CONTEXT_SOURCES" ]; then
case "$AXIS" in
business|design|completeness) ;;
*) echo "::error::only the business, design and completeness axes get context tools"; exit 1 ;;
esac
# The wrappers skip public repos with a notice; this is the
# backstop. Company context must never meet a public PR.
if [ "$REPO_PRIVATE" != "true" ]; then
echo "::error::the context axes run on private repositories only"; exit 1
fi
fi
# Without a checkout the agent reads only what this workflow fetched;
# a shell would let it reach past that. Fail closed on a miswiring.
case "$AXIS" in
business|design)
if [ "$CHECKOUT" != "false" ] || [ "$NO_SHELL" != "true" ]; then
echo "::error::the $AXIS axis requires checkout false and no_shell true"; exit 1
fi ;;
# Only business and design get the change fetched for them; any
# other axis without a checkout would judge with no view of it.
*)
if [ "$CHECKOUT" != "true" ]; then
echo "::error::the $AXIS axis requires checkout true"; exit 1
fi ;;
esac
if [ -z "$PR_NUMBER" ]; then
echo "::error::an axis runs on a pull_request(_target) event only"; exit 1
fi
# Same rule as cursor-review.yml's gate: a fork PR never gets a
# checkout plus a --trust agent run with CURSOR_API_KEY in its env
# (under pull_request_target it would). Failing uploads no verdict,
# which decide reads as "no result" and never approves.
if [ "$HEAD_REPO" != "$BASE_REPO" ]; then
echo "::error::PR is from a fork ($HEAD_REPO != $BASE_REPO); the axes do not run on fork PRs"; exit 1
fi
# actions/checkout with ref '' takes the default branch — mutable
# scripts under a pinned uses:. job.workflow_sha is '' only below
# runner v2.334.0; fail closed rather than load from main.
if ! printf '%s' "$WORKFLOW_SHA" | grep -Eq '^[0-9a-f]{40}$'; then
echo "::error::job.workflow_sha is empty or malformed (Actions runner older than v2.334.0?); refusing to load the axis prompts from a mutable ref"
exit 1
fi
- name: Load cursor-approve assets
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: Comfy-Org/github-workflows
ref: ${{ job.workflow_sha }}
path: _cursor_approve_assets
persist-credentials: false
- name: Check out the PR head (read-only)
if: inputs.checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.commit_sha }}
path: pr
fetch-depth: 0
persist-credentials: false
- name: Fetch the change (no checkout)
if: ${{ !inputs.checkout && (inputs.axis == 'business' || inputs.axis == 'design') }}
env:
GH_TOKEN: ${{ github.token }}
AXIS: ${{ inputs.axis }}
COMMIT_SHA: ${{ inputs.commit_sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
REPO: ${{ github.repository }}
run: |
# The three-dot compare is the merge-base diff of exactly commit_sha,
# the same change the checkout axes review, even if the PR moved on.
mkdir -p pr
compare="repos/$REPO/compare/$BASE_SHA...$COMMIT_SHA"
if [ "$AXIS" = "business" ]; then
# One page, no --paginate: compare paginates COMMITS, and lists the
# files (at most 300) on the first page only. The path is JSON-encoded
# so a newline or tab in a filename cannot forge extra rows.
gh api "$compare?per_page=1" \
--jq '(.files // [])[] | "\(.status)\t+\(.additions)\t-\(.deletions)\t\(.filename | @json)"' \
> pr/changed-files.txt
rows="$(wc -l < pr/changed-files.txt)"
if [ "$rows" -ge 300 ]; then
printf '[file list truncated: the compare API lists at most 300 files]\n' >> pr/changed-files.txt
fi
echo "changed files: $rows"
else
gh api -H 'Accept: application/vnd.github.diff' "$compare" > "$RUNNER_TEMP/pr-full.diff"
limit=204800
size="$(wc -c < "$RUNNER_TEMP/pr-full.diff")"
head -c "$limit" "$RUNNER_TEMP/pr-full.diff" > pr/pr.diff
if [ "$size" -gt "$limit" ]; then
printf '\n[diff truncated: showing %s of %s bytes]\n' "$limit" "$size" >> pr/pr.diff
fi
echo "diff bytes: $size"
fi
- name: Download the prior verdicts
# Written by cursor-approve.yml's start phase in this run. Missing is
# normal (first round, or the read failed): no block, never an error.
if: inputs.prior_rounds > 0
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: prior-axes
path: ${{ runner.temp }}/prior
- name: Render prompt
env:
AXIS: ${{ inputs.axis }}
COMMIT_SHA: ${{ inputs.commit_sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
REPO: ${{ github.repository }}
CHECKOUT: ${{ inputs.checkout }}
CONTEXT_SOURCES: ${{ inputs.context_sources }}
PRIOR_ROUNDS: ${{ inputs.prior_rounds }}
run: |
mkdir -p pr
prior=()
prior_file="$RUNNER_TEMP/prior/prior-axes.json"
if [ "$PRIOR_ROUNDS" != "0" ] && [ -f "$prior_file" ]; then prior=(--prior-file "$prior_file"); fi
# The PR's own text, as data the prompt points at. Kept outside the
# checkout's tracked tree (under .git) so it never shows in a diff.
ctx_dir="pr/.git"
[ -d "$ctx_dir" ] || ctx_dir="pr"
ctx="$ctx_dir/cursor-approve-context.md"
{ printf '# %s\n\n' "$PR_TITLE"; printf '%s\n' "$PR_BODY"; } > "$ctx"
merge_base="$BASE_SHA"
if [ "$CHECKOUT" = "true" ]; then
merge_base="$(git -C pr merge-base "origin/$BASE_REF" "$COMMIT_SHA" 2>/dev/null || printf '%s' "$BASE_SHA")"
fi
{
python3 "$CURSOR_APPROVE_ASSETS/aggregate.py" render \
--axis "$AXIS" --pr-number "$PR_NUMBER" --repo "$REPO" \
--head-sha "$COMMIT_SHA" --merge-base-sha "$merge_base" \
--base-ref "$BASE_REF" --context-file "${ctx#pr/}" "${prior[@]}"
case "$CHECKOUT/$AXIS" in
false/business|false/design) cat "$CURSOR_APPROVE_ASSETS/prompt-no-checkout.md" ;;
esac
if [ -n "$CONTEXT_SOURCES" ]; then cat "$CURSOR_APPROVE_ASSETS/prompt-context-tools.md"; fi
} > "$RUNNER_TEMP/axis-prompt.txt"
- name: Install Cursor agent CLI
run: bash "$CURSOR_REVIEW_ASSETS/install-cursor-cli.sh"
- name: Write the context token file
# The ONLY step that sees LINEAR_KEY / NOTION_TOKEN / SLACK_TOKEN. It
# writes the enabled ones to a 0600 file and passes on nothing but the
# path; context-proxy.py reads and deletes that file before the agent
# starts. A missing secret just leaves that source unconfigured.
if: inputs.context_sources != ''
env:
CONTEXT_SOURCES: ${{ inputs.context_sources }}
LINEAR_KEY: ${{ secrets.LINEAR_KEY }}
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
run: |
python3 - <<'PY'
import json, os
enabled = set(os.environ["CONTEXT_SOURCES"].split(","))
names = {"linear": "LINEAR_KEY", "notion": "NOTION_TOKEN", "slack": "SLACK_TOKEN"}
tokens = {s: os.environ.get(v, "") for s, v in names.items() if s in enabled and os.environ.get(v)}
path = os.path.join(os.environ["RUNNER_TEMP"], "cursor-approve-tokens.json")
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(tokens, f)
print("context sources with a token:", ", ".join(sorted(tokens)) or "none")
PY
- name: Run the axis
working-directory: pr
# No LINEAR_KEY / NOTION_TOKEN / SLACK_TOKEN here, ever (a test enforces
# it): this step only knows where the proxy's token file WAS.
env:
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
MODEL: ${{ inputs.model }}
CONTEXT_SOURCES: ${{ inputs.context_sources }}
NO_SHELL: ${{ inputs.no_shell }}
run: |
installed="$(cursor-agent --version)"
if [ "$installed" != "$CURSOR_CLI_VERSION" ]; then
echo "::error::cursor-agent reports '${installed}', expected the pinned '${CURSOR_CLI_VERSION}'."
exit 1
fi
out="$RUNNER_TEMP/axis-transcript"
mkdir -p "$out"
token_file="$RUNNER_TEMP/cursor-approve-tokens.json"
fifo_dir=""
if [ -n "$CONTEXT_SOURCES" ]; then
# Start the proxy BEFORE the agent, on two FIFOs (opened read-write
# so neither open blocks), without CURSOR_API_KEY in its env. It
# reads and deletes the token file at startup; the agent is not
# started until that file is gone.
fifo_dir="$(mktemp -d)"
mkfifo -m 600 "$fifo_dir/in" "$fifo_dir/out"
set -m
env -u CURSOR_API_KEY python3 "$CURSOR_APPROVE_ASSETS/context-proxy.py" \
--token-file "$token_file" --enable "$CONTEXT_SOURCES" --log "$out/context-calls.jsonl" \
0<>"$fifo_dir/in" 1<>"$fifo_dir/out" 2>"$out/context-proxy-stderr.txt" &
proxy_pid=$!
set +m
for _ in $(seq 1 100); do
[ -e "$token_file" ] || break
kill -0 "$proxy_pid" 2>/dev/null || break
sleep 0.1
done
if [ -e "$token_file" ] || ! kill -0 "$proxy_pid" 2>/dev/null; then
rm -f -- "$token_file"
echo "::error::context-proxy.py did not start (or did not delete its token file); not starting the agent"
head -c 2000 "$out/context-proxy-stderr.txt"
exit 1
fi
fi
# Project-level Cursor config, as cursor-review writes it: replace any
# PR-authored .cursor/mcp.json or cli.json, so the proxy is the ONLY
# MCP server and the PR cannot widen the permissions below.
rm -rf -- .cursor
# A PR-authored ignore file could hide its own changes from the
# read and search tools of the reviewer judging it.
rm -f -- .cursorignore .cursorindexingignore
mkdir -m 700 .cursor
python3 - "$fifo_dir" <<'PY'
import json, os, sys
fifo_dir = sys.argv[1]
servers = {}
allow = []
if fifo_dir:
relay = os.path.join(os.environ["CURSOR_APPROVE_ASSETS"], "mcp-relay.py")
servers["context"] = {"type": "stdio", "command": sys.executable,
"args": [relay, f"{fifo_dir}/in", f"{fifo_dir}/out"]}
tools = {"linear": ("linear_search", "linear_get_issue"),
"notion": ("notion_search", "notion_get_page"),
"slack": ("slack_search", "slack_history")}
for source in os.environ["CONTEXT_SOURCES"].split(","):
allow += [f"Mcp(context:{tool})" for tool in tools[source]]
# Write(**) is the permission behind the edit and delete tools too;
# WebFetch(*) keeps an agent that has read company context off the network.
deny = ["Shell(*)", "Write(**)", "WebFetch(*)"] if os.environ["NO_SHELL"] == "true" else []
# A checkout axis gets its shell by an explicit allow rule. --print
# has no one to approve a command, so with an empty allow list every
# shell call comes back `rejected` and the agent reviews blind; the
# transcript check below fails the job if that ever happens again.
# This is the same unsandboxed-shell-over-PR-code exposure the
# cursor-review panel already accepts (see its header).
if os.environ["NO_SHELL"] != "true":
allow.append("Shell(*)")
for name, value in (("mcp.json", {"mcpServers": servers}),
("cli.json", {"permissions": {"allow": allow, "deny": deny}})):
with open(os.path.join(".cursor", name), "w", encoding="utf-8") as f:
json.dump(value, f)
PY
cursor-agent --print --trust --approve-mcps --output-format stream-json --model "$MODEL" \
< "$RUNNER_TEMP/axis-prompt.txt" \
> "$RUNNER_TEMP/axis-stream.jsonl" 2> "$out/stderr.txt" \
&& rc=0 || rc=$?
if [ -n "$fifo_dir" ]; then
kill -TERM -- "-$proxy_pid" 2>/dev/null || kill "$proxy_pid" 2>/dev/null || true
fi
echo "cursor-agent exit code: $rc"
head -c 2000 "$out/stderr.txt"
# The final reply goes on to extraction; the whole transcript only to
# its own artifact. With no_shell, a shell or file-write tool call in
# the transcript means this cursor-agent did not enforce the deny
# list: fail rather than trust a verdict from an agent that had them.
# Any agent must read the change, so a transcript with NO recognizable
# tool call means the stream-json shape drifted and the guards cannot
# see: fail closed then too, on every axis. With context tools, the
# uploaded transcript keeps only each tool call's name, never its
# arguments or results (Linear, Notion and Slack content). The raw
# stream stays outside the uploaded directory, so a timeout or a
# failed run never uploads it unredacted.
AGENT_RC="$rc" python3 - "$RUNNER_TEMP/axis-stream.jsonl" "$RUNNER_TEMP/axis-raw.txt" \
"$out/transcript.jsonl" <<'PY'
import json, os, sys
forbidden = ("shellToolCall", "writeToolCall", "editToolCall", "deleteToolCall")
redact = bool(os.environ["CONTEXT_SOURCES"])
result, called, recognized, kept = None, set(), 0, []
shell_ok, shell_rejected = 0, 0
with open(sys.argv[1], encoding="utf-8", errors="replace") as f:
for line in f:
try:
event = json.loads(line)
except ValueError:
continue
if not isinstance(event, dict):
continue
if event.get("type") == "result" and isinstance(event.get("result"), str):
result = event["result"]
call = event.get("tool_call")
if event.get("type") == "tool_call" and isinstance(call, dict):
names = [k for k in call if k.endswith("ToolCall")]
recognized += bool(names)
called.update(k for k in call if k in forbidden)
shell = call.get("shellToolCall")
if event.get("subtype") == "completed" and isinstance(shell, dict):
outcome = shell.get("result")
if isinstance(outcome, dict) and "rejected" in outcome:
shell_rejected += 1
else:
shell_ok += 1
if redact:
event = {"type": "tool_call", "subtype": event.get("subtype"),
"call_id": event.get("call_id"), "tools": sorted(call)}
kept.append(json.dumps(event, separators=(",", ":")))
with open(sys.argv[3], "w", encoding="utf-8") as f:
f.write("".join(k + "\n" for k in kept))
if os.environ["AGENT_RC"] != "0":
sys.exit(int(os.environ["AGENT_RC"]))
if os.environ["NO_SHELL"] == "true" and called:
sys.exit(f"::error::no_shell is set but the agent called {', '.join(sorted(called))}; "
"this cursor-agent version does not enforce the deny list")
# Every axis must read something, so no recognizable tool call means
# the stream-json shape drifted (or the agent judged blind): fail.
if not recognized:
sys.exit("::error::the transcript has no recognizable tool call; its stream-json "
"shape may have changed, or the agent read nothing before its verdict")
# A checkout axis whose every shell call was rejected never ran the
# git its prompt points at: the allow rule above stopped working.
if os.environ["NO_SHELL"] != "true" and shell_rejected and not shell_ok:
sys.exit(f"::error::all {shell_rejected} shell call(s) were rejected; this axis is "
"meant to have a shell, so its verdict would be blind")
if result is None:
sys.exit("::error::the cursor-agent transcript has no result event")
with open(sys.argv[2], "w", encoding="utf-8") as f:
f.write(result)
PY
- name: Remove the context token file
if: always() && inputs.context_sources != ''
run: rm -f -- "$RUNNER_TEMP/cursor-approve-tokens.json"
- name: Upload the transcript
# Its own artifact, never named axis-*: only the verdict JSON flows on.
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: transcript-axis-${{ inputs.axis }}
path: ${{ runner.temp }}/axis-transcript/
if-no-files-found: ignore
retention-days: 7
- name: Extract the verdict
env:
AXIS: ${{ inputs.axis }}
COMMIT_SHA: ${{ inputs.commit_sha }}
run: |
mkdir -p "$RUNNER_TEMP/axis-out"
python3 "$CURSOR_APPROVE_ASSETS/aggregate.py" extract --commit-sha "$COMMIT_SHA" \
--raw "$RUNNER_TEMP/axis-raw.txt" --out "$RUNNER_TEMP/axis-out/$AXIS.json"
cat "$RUNNER_TEMP/axis-out/$AXIS.json"
- name: Upload the verdict
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: axis-${{ inputs.axis }}
path: ${{ runner.temp }}/axis-out/${{ inputs.axis }}.json
if-no-files-found: error
retention-days: 7
axis-direct:
# The opt-in direct runner (axis_direct_model), in its OWN job, hard-pinned
# to GitHub-hosted `ubuntu-latest` whatever `runs_on` says — the split
# cursor-review makes for its direct cells and direct judge. This is the
# ONLY job that references ANTHROPIC_API_KEY, so the key never reaches the
# Cursor job's runner (where a prompt-injected `cursor-agent` shell runs
# over PR code), even for a caller that forwards it with the input empty,
# and never a caller's self-hosted pool. A distinct check name, so a
# skipped Cursor job is never mistaken for this one's result.
name: 'axis: ${{ inputs.axis }} (direct)'
if: inputs.axis_direct_model != ''
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
- name: Check inputs
# The Cursor job's checks that apply here, plus the direct id. `model`
# and `runs_on` are not read by this job, so they are not checked.
env:
AXIS: ${{ inputs.axis }}
COMMIT_SHA: ${{ inputs.commit_sha }}
CONTEXT_SOURCES: ${{ inputs.context_sources }}
CHECKOUT: ${{ inputs.checkout }}
PRIOR_ROUNDS: ${{ inputs.prior_rounds }}
AXIS_DIRECT_MODEL: ${{ inputs.axis_direct_model }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
BASE_REPO: ${{ github.repository }}
WORKFLOW_SHA: ${{ job.workflow_sha }}
run: |
# The checkout axes only: business and design read a workflow-fetched
# change through the Cursor runner's no-shell path.
case "$AXIS" in
correctness|completeness|conformance) ;;
*) echo "::error::axis_direct_model runs the checkout axes only (correctness, completeness, conformance), not '$AXIS'"; exit 1 ;;
esac
if [ "$CHECKOUT" != "true" ]; then
echo "::error::the $AXIS axis requires checkout true"; exit 1
fi
# A `type: number` can render as `1.0`.
case "$PRIOR_ROUNDS" in
0|1|2|3|0.0|1.0|2.0|3.0) ;;
*) echo "::error::prior_rounds must be an integer from 0 to 3, got '$PRIOR_ROUNDS'"; exit 1 ;;
esac
if ! printf '%s' "$COMMIT_SHA" | grep -Eq '^[0-9a-f]{40}$'; then
echo "::error::commit_sha must be a full 40-hex SHA"; exit 1
fi
# Validated, not warned past: the caller opted in, so a typo must not
# quietly run Cursor instead (no fallback — the run names its runner).
if ! [[ "$AXIS_DIRECT_MODEL" =~ ^[A-Za-z0-9._-]{1,100}$ ]]; then
echo "::error::axis_direct_model must be a plain Anthropic model id (letters, digits, '.', '_', '-'; at most 100)"; exit 1
fi
# The direct runner carries no MCP server, so the context proxy's
# tools are not wired for it.
if [ -n "$CONTEXT_SOURCES" ]; then
echo "::error::axis_direct_model cannot be combined with context_sources: the direct runner has no context tools"; exit 1
fi
if [ -z "$PR_NUMBER" ]; then
echo "::error::an axis runs on a pull_request(_target) event only"; exit 1
fi
# As the Cursor job: never a fork PR's checkout beside an API key.
if [ "$HEAD_REPO" != "$BASE_REPO" ]; then
echo "::error::PR is from a fork ($HEAD_REPO != $BASE_REPO); the axes do not run on fork PRs"; exit 1
fi
if ! printf '%s' "$WORKFLOW_SHA" | grep -Eq '^[0-9a-f]{40}$'; then
echo "::error::job.workflow_sha is empty or malformed (Actions runner older than v2.334.0?); refusing to load the axis prompts from a mutable ref"
exit 1
fi
echo "::notice::axis $AXIS runs on the direct Anthropic API (Claude Code CLI, model $AXIS_DIRECT_MODEL), not Cursor"
echo "Runner: direct Anthropic API, \`$AXIS_DIRECT_MODEL\`" >> "$GITHUB_STEP_SUMMARY"
- name: Load cursor-approve assets
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: Comfy-Org/github-workflows
ref: ${{ job.workflow_sha }}
path: _cursor_approve_assets
persist-credentials: false
- name: Check out the PR head (read-only)
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.commit_sha }}
path: pr
fetch-depth: 0
persist-credentials: false
- name: Download the prior verdicts
if: inputs.prior_rounds > 0
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: prior-axes
path: ${{ runner.temp }}/prior
- name: Render prompt
# The Cursor job's render, plus the diff the direct runner cannot run
# `git diff` for, and its no-shell addendum.
env:
AXIS: ${{ inputs.axis }}
COMMIT_SHA: ${{ inputs.commit_sha }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_BODY: ${{ github.event.pull_request.body }}
BASE_REF: ${{ github.event.pull_request.base.ref }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
REPO: ${{ github.repository }}
PRIOR_ROUNDS: ${{ inputs.prior_rounds }}
run: |
prior=()
prior_file="$RUNNER_TEMP/prior/prior-axes.json"
if [ "$PRIOR_ROUNDS" != "0" ] && [ -f "$prior_file" ]; then prior=(--prior-file "$prior_file"); fi
# Under .git, so neither file ever shows in a diff.
ctx_dir="pr/.git"
ctx="$ctx_dir/cursor-approve-context.md"
{ printf '# %s\n\n' "$PR_TITLE"; printf '%s\n' "$PR_BODY"; } > "$ctx"
merge_base="$(git -C pr merge-base "origin/$BASE_REF" "$COMMIT_SHA" 2>/dev/null || printf '%s' "$BASE_SHA")"
# Rule 1's exact diff. `--text --no-textconv --no-ext-diff`: the
# checkout's own .gitattributes (`-diff`, `binary`, a textconv
# driver) must not shrink a file's change to "Binary files differ"
# in front of the axis judging it, which has no shell to look past it.
git -C pr diff --text --no-textconv --no-ext-diff \
"$merge_base...$COMMIT_SHA" > "$ctx_dir/cursor-approve-pr.diff"
echo "diff bytes: $(wc -c < "$ctx_dir/cursor-approve-pr.diff")"
{
python3 "$CURSOR_APPROVE_ASSETS/aggregate.py" render \
--axis "$AXIS" --pr-number "$PR_NUMBER" --repo "$REPO" \
--head-sha "$COMMIT_SHA" --merge-base-sha "$merge_base" \
--base-ref "$BASE_REF" --context-file "${ctx#pr/}" "${prior[@]}"
cat "$CURSOR_APPROVE_ASSETS/prompt-direct-runner.md"
} > "$RUNNER_TEMP/axis-prompt.txt"
- name: Check the Anthropic API key
# The direct runner only. An absent key FAILS the axis — no verdict,
# which decide reads as untrusted — never a quiet fall back to Cursor.
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
if [ -z "${ANTHROPIC_API_KEY:-}" ]; then
echo "::error::axis_direct_model is set but the ANTHROPIC_API_KEY secret did not reach this workflow; the axis does not fall back to Cursor. Pass the secret through from the caller."
exit 1
fi
- name: Install Claude Code CLI
# Pinned in .github/cursor-review/package.json, read exactly as
# `review-openai-direct`'s `Install Codex CLI` reads its pin.
run: |
manifest="$CURSOR_REVIEW_ASSETS/package.json"
if [ ! -f "$manifest" ]; then
echo "::error::$manifest is missing — it is the single source of truth for the direct runner's CLI pin."
exit 1
fi
if ! version=$(jq -r '
if type != "object" then ""
elif (.dependencies | type) != "object" then ""
else (.dependencies["@anthropic-ai/claude-code"] // "")
end' "$manifest"); then
echo "::error::$manifest is not valid JSON — it is the single source of truth for the direct runner's CLI pin."
exit 1
fi
version="$(printf '%s' "$version" | tr -d '\r\n')"
case "$version" in
""|*[!0-9.]*)
echo "::error::dependencies[\"@anthropic-ai/claude-code\"] must be an EXACT version (digits and dots only, no ^ or ~); got a value this job will not install."
exit 1 ;;
esac
echo "CLAUDE_CLI_VERSION=$version" >> "$GITHUB_ENV"
npm install -g "@anthropic-ai/claude-code@${version}"
- name: Verify Claude Code CLI version
# As `Verify Codex CLI version`: `claude --version` prints
# `2.1.286 (Claude Code)`, so the FIRST field is the version.
run: |
installed="$(claude --version 2>/dev/null | head -n 1 | tr -d '\r' | awk '{print $1}')"
if [ "$installed" != "$CLAUDE_CLI_VERSION" ]; then
echo "::error::claude reports '${installed}', expected the pinned '${CLAUDE_CLI_VERSION}'."
exit 1
fi
echo "claude ${installed}"
# The confinement rests on these flags, so a bump whose CLI dropped
# or renamed one fails HERE, by name, rather than at argv parsing
# mid-review — or, worse, by silently running unconfined.
help="$(claude --help 2>&1)"
for flag in --restricted --setting-sources --strict-mcp-config \
--disallowedTools --permission-prompts --no-session-persistence; do
if ! printf '%s\n' "$help" | grep -qE -- "(^|[[:space:],])${flag}([[:space:],]|$)"; then
echo "::error::claude ${installed} does not advertise ${flag}, which the direct runner's confinement relies on."
exit 1
fi
done
- name: Prepare the checkout for the direct runner
# cursor-review's direct cells, for the same reasons: a planted
# symlink could lead the file tools out of the tree, and a PR-authored
# Claude config (settings env, hooks, MCP servers) must never be
# there to load. `--restricted` and `--setting-sources ""` already
# ignore the config; this removes it outright. And, as the Cursor
# runner drops `.cursorignore`: Grep and Glob are ripgrep, which obeys
# `.ignore` and `.rgignore`, so a PR could plant one to hide its own
# files from the search. (`.gitignore` stays: deleting it would hide
# its own content, and the written diff lists every changed file.)
run: |
find pr -type l -delete
rm -rf pr/.claude pr/.mcp.json pr/CLAUDE.local.md
find pr -type f \( -name .ignore -o -name .rgignore \) -not -path 'pr/.git/*' -delete
- name: Run the axis (direct)
id: axis_direct
# Step-capped below the job's 20, so the usage record still runs after
# a timeout. A timeout or any failure leaves no raw reply, so the
# verdict is not extracted or uploaded: decide reads "no result".
timeout-minutes: 15
working-directory: pr
env:
AXIS: ${{ inputs.axis }}
MODEL: ${{ inputs.axis_direct_model }}
# Process env of this ONE step only: never argv, never a file.
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
# No telemetry or error report may carry private-diff context.
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1'
run: |
out="$RUNNER_TEMP/axis-transcript"
mkdir -p "$out"
# A fresh, empty config dir, as the direct cells use.
CLAUDE_CONFIG_DIR="$(mktemp -d -p "$RUNNER_TEMP")"
export CLAUDE_CONFIG_DIR
# The direct cells' invocation, minus their submission MCP server
# (the axis answers in its reply, as the Cursor runner's does): Read,
# Grep and Glob only, confined to the checkout by `--restricted`, with
# /proc and /sys denied independently of it (`/proc/self/environ`
# holds the key). `--strict-mcp-config` with no config = no MCP at
# all. Deliberately NOT `--bare`: on the pinned CLI it drops Grep and
# Glob. `xhigh` is the effort tier the Cursor default
# (`claude-opus-5-5-xhigh`) runs at. The result JSON is never echoed:
# the run log is public and the reply is steered by PR text.
claude -p --restricted --setting-sources "" \
--tools "Read,Grep,Glob" \
--strict-mcp-config \
--allowedTools "Read,Grep,Glob" \
--disallowedTools "Read(//proc/**),Read(//sys/**)" \
--permission-prompts none \
--model "$MODEL" --effort xhigh \
--output-format json --no-session-persistence \
< "$RUNNER_TEMP/axis-prompt.txt" > /tmp/axis-claude-result.json \
&& rc=0 || rc=$?
echo "claude exit code: $rc"
if [ "$rc" != "0" ]; then
# WHY, from an allowlist of provider error metadata, never prose.
python3 -I "$CURSOR_REVIEW_ASSETS/agent-error.py" \
--events /tmp/axis-claude-result.json --label "axis-${AXIS}/${MODEL}" || true
fi
# `-I`: the cwd is the PR checkout, and a stdin script puts the cwd
# first on sys.path, so a PR-planted json.py would run with the key
# in its env. The same three guards as the Cursor runner: the agent
# exited 0, it read something (one turn = answered blind), and it
# produced a reply. All three fail CLOSED: a result shape this guard
# does not recognize (a CLI bump renaming `num_turns`) is not a pass.
# Plus groom's tripwire: a reply carrying the key is never published
# (to the transcript, the card or the job summary) — checked on the
# raw file AND on the decoded result, where a JSON-escaped key turns
# literal; `Extract the verdict` checks once more after its decode.
AGENT_RC="$rc" python3 -I - /tmp/axis-claude-result.json "$RUNNER_TEMP/axis-raw.txt" \
"$out/result.json" <<'PY'
import json, os, sys
try:
with open(sys.argv[1], encoding="utf-8", errors="replace") as f:
text = f.read()
except OSError:
text = ""
try:
result = json.loads(text)
except ValueError:
result = None
key = os.environ.get("ANTHROPIC_API_KEY", "")
if key and (key in text or key in json.dumps(result, ensure_ascii=False)):
sys.exit("::error::the direct axis reply contains ANTHROPIC_API_KEY; refusing to publish it (possible prompt-injection exfil)")
with open(sys.argv[3], "w", encoding="utf-8") as f:
f.write(text)
if os.environ["AGENT_RC"] != "0":
sys.exit(int(os.environ["AGENT_RC"]))
if not isinstance(result, dict):
sys.exit("::error::claude left no JSON result object")
if result.get("is_error") is True:
sys.exit("::error::claude reported an error result")
turns = result.get("num_turns")
if type(turns) is not int:
sys.exit("::error::the claude result has no integer num_turns; cannot tell whether the axis read the change")
if turns < 2:
sys.exit("::error::the direct axis answered in one turn, without reading the change")
reply = result.get("result")
if not isinstance(reply, str):
sys.exit("::error::the claude result has no reply text")
with open(sys.argv[2], "w", encoding="utf-8") as f:
f.write(reply)
PY
- name: Upload the transcript
# Its own artifact, never named axis-*: only the verdict JSON flows on.
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: transcript-axis-${{ inputs.axis }}
path: ${{ runner.temp }}/axis-transcript/
if-no-files-found: ignore
retention-days: 7
- name: Extract the verdict
# The key tripwire again, AFTER extraction has decoded the reply: a key
# the model wrote with JSON escapes passes the raw check in the run
# step and only becomes literal here. Checked before the verdict is
# printed to the (public) log or uploaded for the card.
env:
AXIS: ${{ inputs.axis }}
COMMIT_SHA: ${{ inputs.commit_sha }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
mkdir -p "$RUNNER_TEMP/axis-out"
python3 "$CURSOR_APPROVE_ASSETS/aggregate.py" extract --commit-sha "$COMMIT_SHA" \
--raw "$RUNNER_TEMP/axis-raw.txt" --out "$RUNNER_TEMP/axis-out/$AXIS.json"
python3 -I - "$RUNNER_TEMP/axis-out/$AXIS.json" <<'PY'
import json, os, sys
with open(sys.argv[1], encoding="utf-8") as f:
text = f.read()
key = os.environ.get("ANTHROPIC_API_KEY", "")
if key and (key in text or key in json.dumps(json.loads(text), ensure_ascii=False)):
os.remove(sys.argv[1])
sys.exit("::error::the direct axis verdict contains ANTHROPIC_API_KEY; refusing to publish it (possible prompt-injection exfil)")
PY
cat "$RUNNER_TEMP/axis-out/$AXIS.json"
- name: Upload the verdict
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: axis-${{ inputs.axis }}
path: ${{ runner.temp }}/axis-out/${{ inputs.axis }}.json
if-no-files-found: error
retention-days: 7
- name: Record token usage
# The direct runner only: cursor-review's direct cells' script, byte
# for byte but for the result path (a test pins it), review type
# `axis-<axis>`. Counts and model ids only, nothing model-written; an
# axis that left no result (a timeout) records `measured: false`,
# never a guessed zero. Last, and best effort: accounting must never
# fail the axis or delay its verdict upload.
if: always() && steps.axis_direct.outcome != 'skipped'
continue-on-error: true
timeout-minutes: 2
env:
MODEL: ${{ inputs.axis_direct_model }}
REVIEW_TYPE: axis-${{ inputs.axis }}
RUN_ATTEMPT: ${{ github.run_attempt }}
run: |
mkdir -p /tmp/usage-out
python3 -I - <<'PY'
import json, os, re
malformed = []
def count(value, field):
# A token count is a plain non-negative integer; absent reads as
# 0. Anything else makes the whole record unmeasured.
if value is None:
return 0
if type(value) is int and value >= 0:
return value
malformed.append(field)
return 0
def mapping(value, field):
if value is None or isinstance(value, dict):
return value or {}
malformed.append(field)
return {}
try:
with open("/tmp/axis-claude-result.json", encoding="utf-8") as f:
result = json.load(f)
except (OSError, ValueError):
result = None
if not isinstance(result, dict):
result = {}
usage = result.get("usage")
record = {
"model": os.environ["MODEL"],
"review_type": os.environ["REVIEW_TYPE"],
"run_attempt": int(os.environ.get("RUN_ATTEMPT") or 0),
"measured": False,
}
if isinstance(usage, dict):
written = mapping(usage.get("cache_creation"), "cache_creation")
totals = {
"input_tokens": count(usage.get("input_tokens"), "input_tokens"),
"output_tokens": count(usage.get("output_tokens"), "output_tokens"),
"cache_read_input_tokens": count(usage.get("cache_read_input_tokens"), "cache_read_input_tokens"),
"cache_creation_input_tokens": count(usage.get("cache_creation_input_tokens"), "cache_creation_input_tokens"),
"ephemeral_5m_input_tokens": count(written.get("ephemeral_5m_input_tokens"), "ephemeral_5m_input_tokens"),
"ephemeral_1h_input_tokens": count(written.get("ephemeral_1h_input_tokens"), "ephemeral_1h_input_tokens"),
}
# Per model, in case the CLI ever spends on a second one.
models = {}
for name, m in mapping(result.get("modelUsage"), "modelUsage").items():
if not re.fullmatch(r"[A-Za-z0-9._-]{1,100}", name) or not isinstance(m, dict):
malformed.append("modelUsage")
continue
models[name] = {
"input_tokens": count(m.get("inputTokens"), "inputTokens"),
"output_tokens": count(m.get("outputTokens"), "outputTokens"),
"cache_read_input_tokens": count(m.get("cacheReadInputTokens"), "cacheReadInputTokens"),
"cache_creation_input_tokens": count(m.get("cacheCreationInputTokens"), "cacheCreationInputTokens"),
}
if not malformed:
record.update(measured=True, usage=totals, models=models)
for field in ("num_turns", "duration_ms"):
value = result.get(field)
record[field] = value if type(value) is int and value >= 0 else 0
with open("/tmp/usage-out/usage.json", "w", encoding="utf-8") as f:
json.dump(record, f, indent=2)
# Field names only, never values: the run log is public.
print(f"Token usage recorded: measured={str(record['measured']).lower()}"
+ (f" (malformed: {', '.join(sorted(set(malformed)))})." if malformed else "."))
PY
- name: Upload usage artifact
# The cells' `usage-direct-<review_type>-<model>` family. Not `axis-*`,
# so decide never reads it as a verdict. Time-boxed and best effort.
id: usage_upload
if: always() && steps.axis_direct.outcome != 'skipped'
continue-on-error: true
timeout-minutes: 2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: usage-direct-axis-${{ inputs.axis }}-${{ inputs.axis_direct_model }}
path: /tmp/usage-out/usage.json
# `error`, not `ignore`: a record step that died before writing must
# reach the warning below, not upload nothing silently.
if-no-files-found: error
retention-days: 30
- name: Warn on unrecorded usage
# `continue-on-error` hides a failed upload (a squatted name 409s, or
# no usage.json because the record step died before writing one);
# say so on the run, so the scorecard does not price a missing record
# as zero.
if: always() && steps.usage_upload.outcome == 'failure'
continue-on-error: true
timeout-minutes: 1
run: echo "::warning::The direct axis's usage artifact did not upload; this axis's direct-API spend is unrecorded for this run."