Skip to content

fix(pm): dispatch-gates scores a command whose workflow step passes an expression through env: as NOT MEASURED, like an argv-carried one (#15761) - #15830

Merged
baozhoutao merged 3 commits into
mainfrom
claude/issue-15761-dispatch-gates-env-carried-values
Sep 5, 2026
Merged

fix(pm): dispatch-gates scores a command whose workflow step passes an expression through env: as NOT MEASURED, like an argv-carried one (#15761)#15830
baozhoutao merged 3 commits into
mainfrom
claude/issue-15761-dispatch-gates-env-carried-values

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #15761

dispatch-gates' "takes a VALUE FROM THE WORKFLOW" detector read the run: argv and nothing else, so a step that hands its command the input through env: — with a bare argv — scored as a command a dev can paste.

The specimen

.github/workflows/partof-closing-keyword-guard.yml:91-94

        env:
          PR_BODY: ${{ github.event.pull_request.body }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: node scripts/check-partof-closing-keyword.mjs

Positive control, the gate's own refusal (scripts/check-partof-closing-keyword.mjs:264-266, run here at HEAD):

$ node scripts/check-partof-closing-keyword.mjs ; echo EXIT=$?
check:partof-closing-keyword: NOT WIRED — neither PR_BODY nor PR_NUMBER is set, so this run
was handed no pull request and judged nothing. This is a wiring or usage failure, NOT a
verdict: it says nothing about whether any PR body contradicts itself, and no author caused it.
EXIT=2

⛔ The repair REFUSED: moving PR_BODY into the argv so the existing detector sees it. That env: spelling is a security decision the workflow states at length (:65-82); reopening a deliberately closed injection surface to fix a display bug trades the two the wrong way round. The repair is on the reader's side.

What changed

env: is read only at the step's own key column, only its direct children, block form only; a job-level or flow-mapping env: is not read. Every one of those under-reports — the loud direction — and none appears in this tree.

The class is three families wide, not one

Measured on this tree: 405 run: steps, 52 carrying an env: expression, 13 of those invoking a check. Eight spell every env name in the command itself and are filtered out. Of the five reaching the limbs, the conjunction selects three — each verified by running it here, each refusing rather than judging:

command exit says
node scripts/check-partof-closing-keyword.mjs 2 NOT WIRED — neither PR_BODY nor PR_NUMBER is set
node scripts/check-single-claim-paths.mjs 2 NOT WIRED — PR_NUMBER is not set
node scripts/check-required-contexts.mjs --verify-required-set 2 NOT VERIFIED — … answered HTTP 401

A fourth, scripts/pm/check-half-states.mjs --format=markdown --provenance="$PROVENANCE", was already NOT MEASURED through its argv and only gains the two env names it also carries.

The three limbs each keep a live family OUT of the classification: render-release-coverage-anchor.mjs --self-test (selfTest), check:console-injection (direct — its key drops the argv, and ci.yml/release.yml/the root manifest run it in different environments), check-governed-queue-guard.mjs (ciOnly — #14004's own specimen, which would otherwise be printed as two omissions).

Live before/after

node scripts/pm/dispatch-gates.mjs --commands scripts/pm/check-half-states.mjs --repo objectstack-ai/objectstack — the card's own change set, exit 0 both sides:

$ diff before.stdout after.stdout
9d8
< node scripts/check-partof-closing-keyword.mjs

31 commands → 30. stderr, before:

  + 4 famil(ies) matched by path take a VALUE FROM THE WORKFLOW and are NOT above — their argv
    carries a variable with no value outside a CI run.
      ⊘ NOT MEASURED — scripts/check-cross-package-test-inputs.mjs --union-into "$RUNNER_TEMP/turbo-ls.json" …
      ⊘ NOT MEASURED — scripts/check-shard-attestation.mjs --emit --job test --shard ${{ matrix.shard }} …
      ⊘ NOT MEASURED — scripts/check-test-completeness.mjs "$RUNNER_TEMP/test-core.log" …
      ⊘ NOT MEASURED — scripts/pm/check-half-states.mjs --format=markdown --provenance="$PROVENANCE"

after:

  + 5 famil(ies) matched by path take a VALUE FROM THE WORKFLOW and are NOT above — their argv or
    their step's `env:` carries a variable with no value outside a CI run.
      ⊘ NOT MEASURED — scripts/check-cross-package-test-inputs.mjs --union-into "$RUNNER_TEMP/turbo-ls.json" …
      ⊘ NOT MEASURED — scripts/check-partof-closing-keyword.mjs
      ⊘ NOT MEASURED — scripts/check-shard-attestation.mjs --emit --job test --shard ${{ matrix.shard }} …
      ⊘ NOT MEASURED — scripts/check-test-completeness.mjs "$RUNNER_TEMP/test-core.log" …
      ⊘ NOT MEASURED — scripts/pm/check-half-states.mjs --format=markdown --provenance="$PROVENANCE"

pnpm check:partof-closing-keyword — the form that takes nothing from the workflow — is still in stdout, line 24. The human rendering names the row:

  - node scripts/check-partof-closing-keyword.mjs   [partof-closing-keyword-guard.yml]   matched via …
      ⛔ NOT RUNNABLE LOCALLY — 2 value(s) come from the workflow: env PR_BODY, env PR_NUMBER
      ⊘ NOT MEASURED — nothing this card can run answers node scripts/check-partof-closing-keyword.mjs.
        The value reaches its script through the step's `env:` and not through a flag this tool could
        default, so there is no local invocation of it, and this tool will ⛔ not invent one.

Pins — self-test 1478 → 1496, 1496 cases pass

Fixture: the specimen shape (bare argv + env: expression, read on either side of run:); a LITERAL env: value stays runnable ($HOME/not-an-expression included — Actions does not substitute shell forms in an env value); the argv-carried regression control; the one-value-one-carrier filter; an env: nested under with: is not the step's own; the classic and compact fixtures above still carry no env expression. Unit: the four limbs of workflowEnvValues. Live, on the specimen workflow's own change set: the bare invocation is no longer offered as runnable, it is named ⊘ NOT MEASURED on stderr, and pnpm check:partof-closing-keyword is still in the list.

Ablation — trap-guarded, blob-hash restore

stepEnvExpressionVariables made to return [] (marker injected, grep -c = 1, git hash-object moved off the HEAD blob before the run):

MUTATED_SELFTEST_EXIT=1
MUTATED_FAILED_CASES=5
  ✗ ⭐ a bare argv beside an `env:` expression carries the env NAMES the step passes it
  ✗ …and it reads the `env:` block whichever side of `run:` the step writes it on
  ✗ the same walk, read directly: one step per `run:` key, each with its own env
  ✗ ⭐ the bare invocation whose input arrives through `env:` is NO LONGER offered as runnable
  ✗ ⭐ …it is NAMED as NOT MEASURED instead, on the stream that cannot corrupt the harvest
MUTATED_BARE_IN_COMMANDS=1

— the live defect reproduced under the mutation. The two negative controls stayed green under it, which is correct: they assert an ABSENCE the mutation trivially satisfies. Restored git checkout HEAD -- scripts/pm/dispatch-gates.mjs: git diff HEAD 0 bytes, git status --porcelain empty, git hash-object = 2ad8471ac495f7e6f763c14c21513dd3c413297b = git rev-parse HEAD:scripts/pm/dispatch-gates.mjs.

Verification

Derived at this head: node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstack → exit 0, 28 commands, all run. 24 exit 0. Four are NOT MEASURED in this container, by name, all one cause — this worktree runs dispatch-gates from a bare checkout with no pnpm install:

  • node packages/lint/scripts/check-reference-carrier-shape.mjs — exit 3, ts-parse: PREREQUISITE NOT MET — the dependency typescript is not installed
  • node packages/lint/scripts/check-reference-carrier-shape.mjs --self-test — exit 3, same
  • pnpm check:driver-memory-census — exit 3, same
  • node scripts/check-comment-mask-corpus.mjs — exit 1, ERR_MODULE_NOT_FOUND: Cannot find package '@typescript-eslint/parser' — the same absence, reported before the gate body runs, so it is NOT a red gate

node scripts/check-nul-bytes.mjs → exit 0, OK (scanned 7624 text file(s) … no raw ASCII control bytes); plus a direct grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over the edited file, no match.
node scripts/pm/check-governed-merges.mjs --test scripts/pm/dispatch-gates.mjs → exit 0, ✅ NOT governed — ordinary queue landing applies to a PR with exactly this file list.

Changeset

skip-changeset: the diff is one repo-internal tool file under scripts/pm/ and publishes nothing from any released package — AGENTS.md, Post-Task Checklist §3: "that label is for a diff that publishes nothing from any released package."

Serial file

scripts/pm/dispatch-gates.mjs is also touched by #15806 (comment-only citation migrations plus one residue-ledger reason string). At the time of writing #15806 is open, so the textual probe was run instead of a merge: git merge-tree --write-tree origin/claude/issue-15765-scripts-symbol-anchor-corpus HEAD → exit 0, tree 95de6d67c66338cc8a203bc3708bbb19d97e9583. No conflict. This branch already carries a merge of origin/main at 54bb2f125 (merge commit, no rebase), and the self-test was re-run on the merged tree: ✓ dispatch-gates self-test: 1496 cases pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk


Generated by Claude Code

…as value-bearing

The "takes a VALUE FROM THE WORKFLOW" detector read the `run:` argv only, so a
step that passes its input through `env:` — with a bare argv — scored as a
command a dev can paste. `.github/workflows/partof-closing-keyword-guard.yml`
is the specimen: PR_BODY / PR_NUMBER arrive through `env:` and
`node scripts/check-partof-closing-keyword.mjs` was offered as runnable, while
the gate's own refusal calls that run NOT WIRED and exits 2.

`runCommandSteps` now returns each `run:` step with the `env:` names whose
value is an Actions expression, minus the ones the command line spells for
itself (those are argv-carried and already read). `workflowEnvValues` applies
the same shape of conjunction #14004 used for the event payload, and the union
feeds the existing NOT-MEASURED bucket unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
…478 -> 1496)

Fixture pins for the specimen shape, the literal-`env:` negative, the
argv-carried regression control, the one-value-one-carrier filter that keeps
#15441's repaired `--base` families runnable, and the four limbs of
`workflowEnvValues`. Plus the live pin on the specimen workflow's own change
set: the bare invocation leaves `--commands` and is named NOT MEASURED, while
the `pnpm check:` form stays.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Flipped ready + auto-merge enabled (10:04Z, method: MERGE). The serial-file hold lifted: #15806 landed 09:52Z as b2375921b. Readings before the flip, copied from run output: git merge-tree --write-tree origin/main <branch> → EXIT=0 against b2375921b; on a detached probe of the branch merged with that main (MERGE_EXIT=0, tree a6c4500ce) node scripts/pm/dispatch-gates.mjs --self-test✓ dispatch-gates self-test: 1511 cases pass. EXIT=0 (1496 from this PR + #15806's 15), and the live --commands scripts/pm/check-half-states.mjs derive → EXIT=0 with the bare node scripts/check-partof-closing-keyword.mjs absent. All seven required contexts on b90481041 read non-failing (Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Governed Surface Queue Guard success; Build Core, Temporal Conformance skipped). Watched; on landing #15761 closes.


Generated by Claude Code

@baozhoutao
baozhoutao added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit d701e65 Sep 5, 2026
36 checks passed
@baozhoutao
baozhoutao deleted the claude/issue-15761-dispatch-gates-env-carried-values branch September 5, 2026 10:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants