Skip to content

fix(agents,scripts): PIPESTATUS/pipefail are exit-code-safe only when the downstream reads to EOF - #13427

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-12979-pipestatus-head-false-green
Aug 30, 2026
Merged

fix(agents,scripts): PIPESTATUS/pipefail are exit-code-safe only when the downstream reads to EOF#13427
os-zhuang merged 1 commit into
mainfrom
claude/issue-12979-pipestatus-head-false-green

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Fixes #12979

Both copies of the same wrong fact are fixed in this one PR, per the 2026-08-28 grading comment on the card (which explicitly permitted this one-time cross-domain pairing: splitting the halves would leave one copy still misleading).

Half 1 — .claude/agents/os-dev.md (governed face: this PR stays draft, human merge)

  • The safe-forms list in the exit-code-capture discipline no longer offers set -o pipefail and PIPESTATUS[0] unconditionally. Per the grading's fence ("修正的形态要具体,不要只删一项"), both are downgraded to "safe only when the downstream reads to EOF", with | head -N named as the measured counterexample and the mechanism kept in the text: head -N closes the read end early, the producer takes EPIPE/SIGPIPE and exits 0 — the pipe changed the producer's exit code, so PIPESTATUS reports that 0 faithfully and pipefail reads the same changed status. Only redirect-then-capture is immune. The EXIT=$?-after-tail trap stays in the paragraph (the advisory is not simplified to "do not pipe").
  • The corollary the grading asked for is written in the same paragraph, not gate-scoped: any cmd | head followed by an exit-code read is hit (git grep, node alike); reading the output is fine, reading the exit code is a false green.
  • The second carrier (the zero-matched pnpm --filter bullet, which formerly recommended a bare PIPESTATUS read) now points to redirect-then-capture and cross-references the mechanism paragraph.
  • Line-neutral at the ratchet ceiling: 466 lines before and after, every touched line within the 120-byte line cap, paid by in-line compression inside the two touched bullets only (dropped: the typecheck anecdote, two explanatory clauses; kept: both trap statements, the unfalsifiability note, the report-side rule, the no-mechanization note).

Half 2 — scripts/check-i18n-bundles.mjs refusal-banner advisory

The banner warned against | tail -4 — measured, that shape forwards the true status 1 via PIPESTATUS[0]; the dangerous shape is | head, which the old advisory left unnamed, steering readers away from the safe shape and toward the only one that lies. The advisory now names the true hazard, the mechanism, and the safe capture (redirect-then-capture, exit code taken before any pipe). Prose only: same exit codes (self-test 0, refusal 1), no behavioural change.

Measurement — reproduced on this tree before writing (base 94efa2a, unbuilt CLI, pnpm check:i18n refusing with PREREQUISITE NOT MET)

  • direct, redirect-then-capture: EXIT=1 (ground truth red)
  • 2>&1 | head -20: PIPESTATUS[0]=0 — false green
  • 2>&1 | tail -4: PIPESTATUS[0]=1 — faithful
  • set -o pipefail with | head -20: EXIT=0 — false green
  • control, same session: pnpm check:nul-bytes direct EXIT=0 — the instrument returns both values, so the zeros above are readings, not a stuck value

Verification — union run at 9f1652c (the final commit)

Derived with node scripts/pm/dispatch-gates.mjs (no paths; 2-path changeset, header names this tree at 94efa2a). All matched families run locally and green, quoting each gate's own verdict line:

  • check:pm-skill-ratchet — "os-dev.md is 466 lines (ceiling 466; headroom 0)" and "widest table row is 0 bytes (pin 0)" — re-run at 9f1652c
  • check:nul-bytes — "OK (scanned 7438 text file(s) … no raw ASCII control bytes)" — re-run at 9f1652c
  • node scripts/check-i18n-bundles.mjs --self-test — "all go red, and stay distinct" — re-run at 9f1652c, plus the live refusal path re-exercised after the edit: still exit 1, new banner prints with the literal PIPESTATUS spelling intact
  • os-dev.md family: check:agent-model-declared · check:agent-test-spelling · check:doc-authoring · check:pm-governed-merges (206 assertions) · check:pm-skill-id-lint (23 files clean) · check:skill-frame-sync (4 copies isomorphic) — green
  • scripts family: check:bash32-floor (117 cases) · check:cli-command-ids · check:cross-package-test-inputs (both spellings; 24 packages declared) · check:entry-guard (184 files) · check:parse-guard (183 files) · check:pnpm-filter-targets (141/179 resolve) · check:watch-hint-literal (22 declarations) · check-ci-filter-parity (123 globs covered) · check-shard-attestation (2 aggregate gates, 3 legs) — green
  • gate-script-edit convention obligations: node scripts/pm/bare-root-worklist.mjs --self-test and pnpm check:pm-dispatch-gates ("dispatch-gates self-test: 906 cases pass") — green
  • pnpm --filter @objectstack/lint run check:doc-formula-expressions — "22 record-scoped formula example(s) … judged clean" after building the lint closure

NOT MEASURED locally, by each gate's own declaration (CI owns them): check:i18n full bundle comparison (needs the 10-package build closure; the diff is confined to the refusal-banner template literal, so the comparison path is untouched — and CI builds first); check-test-completeness (grades a saved CI test log; exits 3 with "record this gate as NOT MEASURED"); check-governed-queue-guard (reads the CI event payload; runs on this PR).

skip-changeset

The diff releases nothing — an internal agent definition plus a CI-internal gate script's stderr banner. lint.yml's own comment calls the CI-internal-script edit "the textbook skip-changeset case". Label applied at PR-open per the standing terms.

Out of scope, reported on the card

Sibling copies of the same wrong-shape advisory exist in scripts/pm/ci-failure.mjs, scripts/pm/check-half-states.mjs, scripts/import-prerequisite.mjs, scripts/check-i18n-coverage.mjs and scripts/check-test-completeness.mjs. They are outside this card's graded surface (the grading permitted exactly the check-i18n-bundles half) and are left for triage — details in the os-dev report on the card. #12979 names only the two halves fixed here; the sibling copies remain open work.

Session: https://claude.ai/code/session_01EXxTW8mvPBhoHxmyPZ63de

Generated by Claude Code


Generated by Claude Code

… the downstream reads to EOF

The os-dev exit-code discipline listed `set -o pipefail` and `${PIPESTATUS[0]}`
as safe capture forms. Measured on a refusing gate: `| head -N` closes the read
end early, the producer takes EPIPE/SIGPIPE and exits 0 — the pipe changes the
producer's exit code, so PIPESTATUS reports that 0 faithfully and pipefail reads
the same changed status. Only redirect-then-capture is immune. Downgrade both
forms to EOF-conditional, name `| head -N` as the measured counterexample, and
state the corollary: any `cmd | head` followed by an exit-code read is hit,
not just gates.

Same PR, second copy of the same wrong fact: the i18n gate's refusal banner
warned against `| tail -4` (which reads 1, correctly, via PIPESTATUS) and left
`| head` — the shape that actually reads green — unnamed. The advisory now
names the true hazard and the safe capture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXxTW8mvPBhoHxmyPZ63de
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] ${PIPESTATUS[0]} is documented as a SAFE form but reads 0 for a red gate when the downstream reader closes the pipe early (| head)

3 participants