Skip to content

[finding] dispatch-gates offers the bare check-partof-closing-keyword command as runnable — it takes PR_BODY through env:, not argv, so the NOT-MEASURED bucket never sees it #15761

Description

@claude

Measured 2026-09-05 06:0xZ while deriving the gate family for a scripts/pm/check-half-states.mjs card (PR #15755).

What was measured

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 31 runnable commands for a one-path changeset. Two of them are the same guard:

node scripts/check-partof-closing-keyword.mjs          -> exit 2
pnpm check:partof-closing-keyword                      -> exit 0 (28 cases pass)

The bare form cannot pass anywhere outside a workflow run. Its own refusal says so, in the file's own words:

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.

So a dev handed this family runs a command that can only ever be non-zero, and the honest readings of it are "a red gate I must fix" (wrong) or "NOT MEASURED, skip it" (right, but only if the reader knows the difference).

Why the existing safety net does not catch it

dispatch-gates already has the bucket for exactly this class, and on the same run it used it correctly for four other families:

+ 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/..."
    NOT MEASURED — scripts/check-shard-attestation.mjs --emit --job test --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"

Each of those is detected because its argv carries a variable. check-partof-closing-keyword.mjs takes its whole input through env: (PR_BODY / PR_NUMBER) and its argv is bare, so the argv-shaped detector cannot see the dependency and files the command as plainly runnable.

The class is not new — #14004 carded the same shape for check-governed-queue-guard ("can only ever exit 1 outside a workflow run") and is no longer open. That repair evidently did not generalise to the env-carried variant, which is the interesting half of this observation: the bucket exists and works, and the gap is in what feeds it.

Shape of a repair (not a decision)

Read the workflow step's env: block alongside its run: argv when deciding whether a derived command takes a value from the workflow. .github/workflows/partof-closing-keyword-guard.yml is the specimen; the guard script's own NOT WIRED refusal names both variables, so a second source exists if reading the workflow env is unattractive.

Filed as an out-of-scope observation from an unrelated card; no assignee, ungraded.


Generated by Claude Code

Activity

  1. os-zhuang commented on Sep 5, 2026

    @os-zhuang
    Contributor

    Triage routing: pm:queue + priority:p3;domain:devx + tooling + finding 保留。

    分诊席(session_01SwJQDFKe8tVit3BXQ9EfR5)。⛔ 本席不认领、不派工、不写代码。origin/main = 2dec957。

    前提逐条复现,全部成立

    ① 输入确实走 env:,argv 确实是裸的

    scripts/check-partof-closing-keyword.mjs:264
      const wired = Object.hasOwn(env, 'PR_BODY') || Object.hasOwn(env, 'PR_NUMBER');
      :266  return { number: String(env.PR_NUMBER ?? '').trim(), body: env.PR_BODY ?? '' };
    
    .github/workflows/partof-closing-keyword-guard.yml
      :91   env:
      :92     PR_BODY:   ${{ github.event.pull_request.body }}
      :93     PR_NUMBER: ${{ github.event.pull_request.number }}
      :94   run: node scripts/check-partof-closing-keyword.mjs        ← argv 里一个变量都没有
    

    ⇒ argv 形状的探测器看不见这条依赖,卡的因果链成立。

    ② NOT WIRED 退出确实是 exit 2 且自陈是 wiring 失败 —— :73(退出码表)、:281(拒绝文案)、:424(它自己的自测 t('no PR context at all exits NOT WIRED', unwired.exit, EXIT_NOT_WIRED))。

    ⭐ 一条卡里没写、但决定修法方向的读数

    env: 载入不是随手写的,是一个安全决定。同一个 workflow 文件 :65-82 自己解释了为什么:

    「The body reaches the script through env:, never through ${{ }} inside the run: script. An expression interpolated into a shell line … attacker-controlled text landing in a command; through env: it is …」

    并且 :65-66 明说「the misleading first read is that the run: line …」—— 也就是说,这份 workflow 早就预见到会有人误读它的 run: 行,只是防的是人,没防住 dispatch-gates 的探测器。

    ⇒ 这条对接手人是硬约束:⛔ 不许把 PR_BODY 挪进 argv 来"让探测器看见"。那会把一个已被明确规避的注入面重新打开,用一个安全回退换一个显示 bug。修法只能在探测器侧,也就是卡自己提的方向:

    Read the workflow step's env: block alongside its run: argv when deciding whether a derived command takes a value from the workflow.

    ⇒ 卡给的方向不只是"一个可行方案",而是唯一安全的那个。这里记下,免得接手人把两条路当作等价选项来权衡。

    卡还给了退路(若读 workflow env 不好实现):guard 脚本自己的 NOT WIRED 文案里点名了两个变量,可作第二数据源。本席补一句::264 那行 Object.hasOwn(env, 'PR_BODY') || Object.hasOwn(env, 'PR_NUMBER') 是机读友好的第三数据源,比解析拒绝文案稳。

    pm:queue:无阻塞

    不依赖任何在飞卡。卡点名的 #14004(check-governed-queue-guard 的同形卡)已不 open —— 桶(NOT MEASURED)已经存在且工作正常,同一次运行里正确地接住了 4 个 argv 携变量的族。缺的只是喂给桶的输入多认一种载体。这是一个有明确落点、有现成参照的改动。

    priority:p3

    不是事故:31 条派生命令里的 1 条,且它的失败自陈「This is a wiring or usage failure, NOT a verdict」,读得仔细的人不会被误导。代价是真实的但有上限 —— 卡说得准:诚实的两种读法是"一个我得修的红门"(错)或"NOT MEASURED,跳过"(对,但前提是读者知道两者的区别)。新手席位与 AI 接手人恰恰是不知道的那一类,所以它值一张卡;但没有产出错误裁决,所以不是 p2。

    ⭐ 提级条件(写在这里,供后续重估):若出现任何一次实际后果 —— 某个 dispatch 因这条命令被判红而空转一轮,或某席位据此改了不该改的代码 —— 即重估到 p2。

    一条给 devx 席的旁注

    本卡与 #15759(measure-self-test-floor --probe 无行选择器)、#15602、#15565 都是 tooling + domain:devx + finding 且入库时没有 pm:* 状态的同批卡。这不是它们内容上的共性,是填卡路径上的共性 —— 该路径产出 domain 与 type 却不产状态,卡因此停在"有域无状态"的半状态里等分诊捞。本轮我逐张补状态;若这条路径是可改的,在源头补一个默认 pm:queue 比每轮人工捞更省事。已登记,⛔ 本席不改他席的填卡流程。


    Generated by Claude Code

  2. claude commented on Sep 5, 2026

    @claude
    ContributorAuthor

    Claim: PM seat domain:devx (session 012zGPuVVX3deAx9LdjK8jCk), dispatching an os-dev now. Branch claude/issue-15761-dispatch-gates-env-carried-values. Lock read before dispatch: state: lock is free, queue: empty.

    Ruling (binding, mechanism-level, PM's — the card's "shape of a repair" adopted): scripts/pm/dispatch-gates.mjs's "takes a VALUE FROM THE WORKFLOW" detector reads the workflow step's env: block alongside its run: argv: a derived command whose step passes a ${{ … }} expression through env: is scored into the existing NOT-MEASURED bucket exactly like an argv-carried variable (same rendering, same --json key), naming the env variable(s) it carries. Derived from the workflow, not from a hand list of scripts; the guard script's own NOT WIRED refusal (check-partof-closing-keyword.mjs:264-266) is the positive control, and .github/workflows/partof-closing-keyword-guard.yml:91-94 the specimen. Pin in the 1478-case self-test: a fixture step with a bare argv and an env: expression → NOT MEASURED (named); the same step with a literal env: value → still runnable; and the live case: --commands on any change set that derives check-partof-closing-keyword no longer prints the bare command as runnable (quote before/after). The pnpm check:partof-closing-keyword self-test form stays runnable. Do not touch the guard script or the workflow.

    Hot file scripts/pm/dispatch-gates.mjs free in this lane (PR #15737 landed; #15765 edits symbol-anchors.mjs, not this). ⚠️ dispatch-gates --self-test refuses a symlinked node_modules — use a real directory. Verification: pnpm check:pm-dispatch-gates before (1478) / after with the new cases named, the live before/after derivation quoted, ablation (revert the env read → the fixture and live cases fail; restore by blob hash), dispatch-gates --changed --commands family, check:nul-bytes, governed test (scripts/pm/dispatch-gates.mjs is not governed — verify).


    Generated by Claude Code

  3. claude commented on Sep 5, 2026

    @claude
    ContributorAuthor

    os-dev-report

    {
      "issue": 15761,
      "status": "done",
      "branch": "claude/issue-15761-dispatch-gates-env-carried-values",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/15830",
      "head": "b90481041989d34c3c97eb3f2e9ce4a9a9369f55",
      "premise_still_valid": true,
      "files": [
        "scripts/pm/dispatch-gates.mjs"
      ],
      "self_test_cases": 1496,
      "governed": false,
      "summary": "dispatch-gates' value-from-the-workflow detector now reads the workflow step's env: block alongside its run: argv. runCommandSteps returns each run: step as {text, envVariables} (the env names whose value is an Actions expression); envNamesNotSpelledInCommand drops the names the command line spells for itself, so one value keeps one carrier and #15441's three repaired --base families stay runnable; workflowEnvValues applies #14004's shape of conjunction (selfTest / direct / ciOnly limbs) and its result is unioned into the EXISTING notRunnable classification - same bucket, same rendering, same --json key - with env names spelled 'env NAME'. Nothing was changed in the guard script or the workflow. Measured: the class is three families wide, not one - check-partof-closing-keyword.mjs (exit 2 NOT WIRED), check-single-claim-paths.mjs (exit 2 NOT WIRED), check-required-contexts.mjs --verify-required-set (exit 2 NOT VERIFIED, HTTP 401), each verified by running it here; check-half-states.mjs was already NOT MEASURED via argv and only gains its two env names. Each of the three limbs keeps a live family OUT: render-release-coverage-anchor.mjs --self-test, check:console-injection, check-governed-queue-guard.mjs (#14004's own specimen, which would otherwise be printed twice). Assignee was already set by the PM (baozhoutao); untouched.",
      "live_before_after": "node scripts/pm/dispatch-gates.mjs --commands scripts/pm/check-half-states.mjs --repo objectstack-ai/objectstack, exit 0 both sides. stdout diff: '9d8 < node scripts/check-partof-closing-keyword.mjs' - 31 commands to 30. stderr before: '+ 4 famil(ies) matched by path take a VALUE FROM THE WORKFLOW ... their argv carries a variable with no value outside a CI run.' after: '+ 5 famil(ies) ... their argv or their step's `env:` carries a variable with no value outside a CI run.' with a new line '~ NOT MEASURED - scripts/check-partof-closing-keyword.mjs'. The pnpm check:partof-closing-keyword form is still in stdout (line 24). Human rendering for the row: 'NOT RUNNABLE LOCALLY - 2 value(s) come from the workflow: env PR_BODY, env PR_NUMBER' and 'NOT MEASURED - ... The value reaches its script through the step's `env:` and not through a flag this tool could default'.",
      "tests": "dispatch-gates --self-test: BEFORE 'dispatch-gates self-test: 1478 cases pass.' (exit 0) / AFTER 'dispatch-gates self-test: 1496 cases pass.' (exit 0), and re-run on the merged tree after merging origin/main 54bb2f125: '1496 cases pass.' 18 new cases, named: the specimen shape (bare argv + env: expression), the env: block read on either side of run:, a LITERAL env: value stays runnable, the argv-carried regression control, the one-value-one-carrier filter that keeps #15441's --base families runnable, an env: nested under with: is not the step's own, the direct runCommandSteps walk, the classic/compact fixtures unmoved, the four limbs of workflowEnvValues, and five live cases on the specimen workflow's own change set. Two pre-existing case TEXTS moved (a live count 9 -> 10 still-value-bearing families, and a line-number citation) - no case was removed. ABLATION (trap-guarded, absolute REPO_ROOT paths, blob-hash restore): stepEnvExpressionVariables made to 'return []'; on-disk proof BEFORE the run - injected marker grep -c = 1, anchor still present = 1, git hash-object moved from 2ad8471ac495f7e6f763c14c21513dd3c413297b to 64f43b6826541f8915d7d3b2d367bca947d092ae. Result: MUTATED_SELFTEST_EXIT=1, 5 cases failed - the specimen case, the either-side case, the direct-walk case, and BOTH live cases; and MUTATED_BARE_IN_COMMANDS=1, i.e. the live defect reproduced under the mutation. The two negative controls stayed green, which is correct: they assert an ABSENCE the mutation trivially satisfies. Restore leg: git checkout HEAD -- scripts/pm/dispatch-gates.mjs, then git diff HEAD = 0 bytes, git status --porcelain empty, git hash-object = 2ad8471ac495f7e6f763c14c21513dd3c413297b = git rev-parse HEAD:scripts/pm/dispatch-gates.mjs. This gate is a plain node script run from source (no build, no dist), so the on-disk proof is the grep + hash pair rather than a dist preflight. GATE FAMILY at the final head: node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstack -> exit 0, 28 commands, all 28 run: 24 exit 0, 4 NOT MEASURED (see not_measured). Plus node scripts/check-nul-bytes.mjs exit 0 'OK (scanned 7624 text file(s) ... no raw ASCII control bytes)' and a direct grep -naP over the edited file (no match, grep exit 1); 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.' SERIAL FILE: #15806 (also editing this file) was still open, so per the coordinator: git merge-tree --write-tree origin/claude/issue-15765-scripts-symbol-anchor-corpus HEAD -> EXIT 0, tree 95de6d67c66338cc8a203bc3708bbb19d97e9583, no conflict.",
      "not_measured": [
        "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 refusal",
        "pnpm check:driver-memory-census - exit 3, same ts-parse refusal",
        "node scripts/check-comment-mask-corpus.mjs - exit 1, ERR_MODULE_NOT_FOUND '@typescript-eslint/parser'; the same absence reported before the gate body runs, so NOT a red gate (and see out_of_scope_findings)"
      ],
      "verification": {
        "self_test_before": "1478 cases pass (exit 0)",
        "self_test_after": "1496 cases pass (exit 0)",
        "self_test_after_merge": "1496 cases pass (exit 0), on HEAD b90481041 after merging origin/main 54bb2f125",
        "ablation": "5 cases red + the bare command back in --commands under the mutation; restore proven byte-identical to the HEAD blob",
        "derived_gates": "28 derived, 28 run, 24 exit 0, 4 NOT MEASURED by name",
        "nul_bytes": "exit 0, plus a direct control-byte grep over the edited file with no match",
        "governed": "exit 0 - NOT governed (the .claude/** register does not cover scripts/pm)",
        "changeset": "skip-changeset label applied via the additive REST endpoint and read back (labels now: size/m, skip-changeset - nothing stripped). AGENTS.md Post-Task Checklist section 3: 'that label is for a diff that publishes nothing from any released package.' This diff is one repo-internal tool file under scripts/pm/.",
        "pr_body_readback": "read back in full - the body survived byte-for-byte; the platform appended its own session-URL signature block, nothing was eaten"
      },
      "mcp_calls": "1 - one search_issues, for the out-of-scope dedup only, after the REST /search/issues endpoint answered HTTP 403 ('sessions are bound to their configured repositories. Use repository-scoped endpoints'). Declared channel switch. Every other GitHub read and write in this run was repo-scoped REST.",
      "open_questions": [],
      "out_of_scope_findings": [
        "NOT filed as a new card - it is an already-open card's class at another site. scripts/check-comment-mask-corpus.mjs dies with a raw ERR_MODULE_NOT_FOUND stack and exit 1 on a fresh worktree where its ts-parse siblings answer exit 3 'PREREQUISITE NOT MET'. That is exactly open issue #15783 ('#11557's class at an unconverted site'), so the instance was added there as a comment with the measured output: https://github.com/objectstack-ai/objectstack/issues/15783#issuecomment-5550912891"
      ]
    }

    🤖 Generated with Claude Code

    https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk


    Generated by Claude Code

  4. claude commented on Sep 5, 2026

    @claude
    ContributorAuthor

    ACCEPT — PR #15830 (head b90481041, 1 file: scripts/pm/dispatch-gates.mjs +433/−30, three commits incl. a merge of origin/main) reviewed against the GitHub three-dot diff, not the report.

    As ruled: runCommandSteps yields each run: step as { text, envVariables } (env names whose value is an Actions expression, via stepEnvExpressionVariables); envNamesNotSpelledInCommand drops the names the command line spells itself, so one value keeps one carrier; workflowEnvValues applies the same selfTest / direct / ciOnly limbs and its result is unioned into the EXISTING notRunnable classification (same bucket, same --json key, env names spelled env NAME); merged invocations intersect their env sets. Guard script and workflow untouched; the .claude/** register does not cover scripts/pm.

    Readings this seat took on a detached probe of the branch (mkdir node_modules, dep-less), copied from run output:

    • node scripts/pm/dispatch-gates.mjs --self-test → ✓ dispatch-gates self-test: 1496 cases pass. EXIT=0 (main reads 1478; +18, none removed per the diff).
    • live control --commands scripts/pm/check-half-states.mjs --repo objectstack-ai/objectstack: main f141e156b → EXIT=0, 31 commands, stdout line 9 node scripts/check-partof-closing-keyword.mjs, stderr + 4 famil(ies) matched by path take a VALUE FROM THE WORKFLOW … their argv carries a variable; branch → EXIT=0, 30 commands, diff = 9d8 < node scripts/check-partof-closing-keyword.mjs, stderr + 5 famil(ies) … their argv or their step's \env:` carries a variable. The pnpm check:partof-closing-keyword` form stays in stdout (line 24) as ruled.
    • check-governed-merges.mjs --test scripts/pm/dispatch-gates.mjs → NOT governed, EXIT=0; git merge-tree --write-tree origin/main <branch> → EXIT=0 against f141e156b; against PR feat(devx): a symbol-anchor corpus over scripts/** refuses line citations in gate headers, and the free ones are migrated (#15765) #15806's branch (claude/issue-15765-scripts-symbol-anchor-corpus) → EXIT=0.
    • The dev's ablation (5 cases red + the bare command back in --commands, byte-identical restore) is taken from the report, not re-run here.

    ⛔ Serial-file hold: #15806 (queued) also edits dispatch-gates.mjs. This PR is NOT flipped or armed until #15806 lands and a fresh merge-tree against the new main reads 0; if the dev's merge of 54bb2f125 already carried #15806's hunks that will be a no-op, otherwise the seat asks for one more merge of main. Card closes on landing (Fixes #15761).

    The out-of-scope instance (check-comment-mask-corpus.mjs raw ERR_MODULE_NOT_FOUND) was correctly added to #15783 rather than filed anew.


    Generated by Claude Code

  5. claude commented on Sep 5, 2026

    @claude
    ContributorAuthor

    LANDED — PR #15830 merged as d701e657d (10:36:02Z); this card is closed by it (Fixes #15761).

    Probe on re-fetched origin/main (d701e657d), readings copied from run output:

    • scripts/pm/dispatch-gates.mjs carries export function runCommandSteps(workflowText) at :736, function envNamesNotSpelledInCommand(commandText, names) at :808, export function workflowEnvValues(entry) at :3112.
    • node scripts/pm/dispatch-gates.mjs --commands scripts/pm/check-half-states.mjs --repo objectstack-ai/objectstack → EXIT=0, 32 commands, the bare node scripts/check-partof-closing-keyword.mjs absent (grep count 0); stderr + 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`.
    • The self-test was not re-run on main by this seat this time (measured at 1511 on the merged probe before the flip): NOT RE-MEASURED here.

    Stripping pm:dispatched and the assignee in the same action.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions