Skip to content

fix(pm): os-verify-lock reads a redirection's & as a redirection, not as a background operator - #12635

Merged
os-litant merged 3 commits into
mainfrom
claude/issue-12518-verify-lock-redirection
Aug 27, 2026
Merged

fix(pm): os-verify-lock reads a redirection's & as a redirection, not as a background operator#12635
os-litant merged 3 commits into
mainfrom
claude/issue-12518-verify-lock-redirection

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes#12518

exit_certifiable()'s scanner read the & in a 2>&1redirection as a background operator, so a correctly &&-joined command was downgraded from VERDICT command-exit to VERDICT batch-last-exit. The banner it printed told the caller to "join the parts with &&" — a repair the command had already made, so no action could clear it.

The scanner now carries one character of look-behind: the redirection operator it just passed. Look-behind, because the character that disambiguates these tokens is the one before them — in 2>&1 the & is followed by 1, exactly as a backgrounding & before a next command would be. Nothing else about the scanner's semantics moves: ;, newline, |, || and genuine backgrounding all keep their verdicts.

Before / after — the card's two commands, verbatim

Before (at 0043c922):

$ bash scripts/pm/os-verify-lock.sh -c 'true && true'
os-verify-lock: ACQUIRED after 0s — running: true && true
os-verify-lock: VERDICT command-exit 0 · held the lock 0s · waited 0s
$ bash scripts/pm/os-verify-lock.sh -c 'true > /dev/null 2>&1 && true'
os-verify-lock: ⚠ THIS COMMAND'S EXIT CODE CANNOT CERTIFY IT — a part of it is backgrounded with '&'.
os-verify-lock: ACQUIRED after 0s — running: true > /dev/null 2>&1 && true
os-verify-lock: VERDICT batch-last-exit 0 · ⚠ NOT A VERDICT ON THE WHOLE COMMAND — a part of it is
backgrounded with '&', so this number is the LAST part's exit and a failure in an earlier part is
NOT in it · join the parts with '&&' to get a verdict that covers all of them · held the lock 0s

After (at f55ee39d):

$ bash scripts/pm/os-verify-lock.sh -c 'true && true'
os-verify-lock: ACQUIRED after 0s — running: true && true
os-verify-lock: VERDICT command-exit 0 · held the lock 0s · waited 0s
$ bash scripts/pm/os-verify-lock.sh -c 'true > /dev/null 2>&1 && true'
os-verify-lock: ACQUIRED after 0s — running: true > /dev/null 2>&1 && true
os-verify-lock: VERDICT command-exit 0 · held the lock 0s · waited 0s

The warning block is gone entirely — it is not merely reworded.

The implementation was measured, not assumed

The card suggested looking back for an unescaped >. That is the right direction but not the whole grammar, so the forms were enumerated against bash's own parser rather than guessed: declare -f re-prints a function body from the parsed AST, so it reports what bash actually did with each token (bash 5.2.21; bash -c is what runs the string here, so bash's grammar and not POSIX sh's is the authority).

f() { true 2>&1 && true; } -> true 2>&1 && true redirection
f() { true&>/dev/null; } -> true &> /dev/null redirection
f() { true & > /dev/null; } -> true & > /dev/null BACKGROUND
f() { echo \>&true; } -> echo \> & true BACKGROUND
f() { printf "a>"&true; } -> printf "a>" & true BACKGROUND
f() { true 2>&1& true; } -> true 2>&1 & true one of each
f() { true |& cat; } -> true 2>&1 | cat a PIPELINE

Three readings follow, and each is why this is scanner state rather than a look-back into the raw string at i-1:

  • The > must be unescaped and unquoted. echo \>&true and printf "a>"&true both background, and in both the character at i-1 is a >. Only the scanner's own quote/escape state separates them, so every path that skips a character clears the look-behind as it goes.
  • &> must be adjacent. true &> log redirects; true & > log backgrounds. The test is the next character, never the next token.
  • Consuming a redirection's & must not swallow a later backgrounding one — true 2>&1& true carries both, in that order.

|& needed no new case: bash rewrites it to 2>&1 |, so it really is a pipeline and the existing | branch already answers it correctly.

Full verdict matrix, before and after

Driven through the real script, one isolated lock file, at 0043c922 and f55ee39d:

 command BEFORE AFTER
2>&1 true > /dev/null 2>&1 && true batch-last-exit command-exit
>&2 true >&2 && true batch-last-exit command-exit
>& word true >& /dev/null && true batch-last-exit command-exit
&> word true &> /dev/null && true batch-last-exit command-exit
2>&- close true 2>&- && true batch-last-exit command-exit
<&0 dup-in true <&0 && true batch-last-exit command-exit
<&- close-in true <&- && true batch-last-exit command-exit
2>&1- fd move true 2>&1- && true batch-last-exit command-exit
3>&1 4>&2 true 3>&1 4>&2 && true batch-last-exit command-exit
>| noclobber true >| /dev/null && true batch-last-exit command-exit
--- these MUST stay uncertified, and all ten do ---
append-both (see the section below) batch-last-exit batch-last-exit
true background sleep 1 & batch-last-exit batch-last-exit
redirect THEN bg true 2>&1 & true batch-last-exit batch-last-exit
spaced & then > true & true > /dev/null batch-last-exit batch-last-exit
escaped > before & echo \>&true batch-last-exit batch-last-exit
quoted > before & printf "a>"&true batch-last-exit batch-last-exit
pipeline true | cat batch-last-exit batch-last-exit
pipe-both true |& cat batch-last-exit batch-last-exit
or-fallback true || true batch-last-exit batch-last-exit
semicolon true; true batch-last-exit batch-last-exit
backtick echo `true` && true batch-last-exit batch-last-exit

Two rows are beyond the & fix as the card framed it, and both are the same defect class — a redirection operator's second character read as a control operator, in the same scan loop, repaired by the same state:

  • <& input duplication. The card names only >; <&0 and <&- are real bash redirections and were misread identically.
  • >| noclobber override. Read as a pipeline, not as backgrounding. Guarded on > alone, because <| is a bash syntax error and so has no input form to admit.

One form is deliberately left uncertified: the append-both redirection

check:bash32-floor went red on the first draft of this change, and it was right. An & before a doubled > appends both streams on bash 4.0+, but this file is held to a bash 3.2 floor (/usr/bin/env bash is 3.2.57 on macOS, and bash -c is what runs the string, so the host's bash decides). On 3.2 that spelling parses as & then >> — it really is backgrounded there.

Certifying it would have been an under-label on a 3.2 host, which is the one direction this scanner's doctrine forbids. Its meaning is a property of the host rather than of the string, which is the definition of what this scanner "cannot read with confidence", so it takes the uncertified exit reserved for exactly that — with its own note naming the portable spelling, which this change certifies:

os-verify-lock: ⚠ THIS COMMAND'S EXIT CODE CANNOT CERTIFY IT — it appends both streams with an '&'
before a doubled '>', which bash 3.2 (this repo's floor) parses as backgrounding — write
'>> file 2>&1' instead.

Unlike the defect being fixed, that banner's remedy is not already satisfied — it is actionable, and the command it recommends comes out command-exit.

There is deliberately no st_case for it: writing the token in this repo's shell is itself a check:bash32-floor violation, and the gate fires on quoted occurrences too (measured — the two flagged lines were an st_case label and a single-quoted payload handed to bash -c). So the gate that forbids the spelling is also what makes the pin unwritable. The reading is recorded in full in the comment block above exit_certifiable. The same reason removed a |& case from the draft: |& is bash 4.0 as well.

Tests

Regression pins — 17 new st_case cases in the existing self-test suite, in a new (g3) block beside the (g2) verdict-word pins, pinned from both directions:

$ bash scripts/pm/os-verify-lock.sh --self-test # exit 0
✓ a 2>&1 redirection is not a background operator — the reported case
✓ nor is the >&2 form, where the fd number is on the left
✓ nor >&word, which redirects both streams with the & on the RIGHT of >
✓ nor &>word, the same redirection with the & on the LEFT
✓ nor a >&- fd close
✓ nor a <& input duplication, which the > alone would have missed
✓ nor several redirections in one command
✓ and >| is the noclobber REDIRECTION, not a pipeline
✓ the redirect-then-capture shape the gate docs prescribe is certified
✓ and a redirected chain that FAILS still reports the failing exit
✓ a REAL background & is still uncertified
✓ and still says so in the banner, with backgrounding named
✓ a redirection FOLLOWED by a background & still flags the background one
✓ a spaced `& >` is backgrounding, not the adjacent &> redirection
✓ an ESCAPED > before the & does not arm the redirection reading
✓ nor does a QUOTED > before it
✓ and a pipeline whose LEFT side is red still exits 0 through it, uncertified
✓ os-verify-lock self-test: all cases pass. # 151 pass, 0 fail

The card asked for two pins specifically; both are present — true > /dev/null 2>&1 && true is the first case, and sleep 1 & is "a REAL background & is still uncertified".

Ablation — the pins are shown to fail. Run from the committed state at f55ee39d, mutating only the three repaired guards to if false, with the mutation proven on disk by anchored text counts (not by an edit tool's exit code) and the restore proven byte-identical against the HEAD blob:

injected marker occurrences : 3 (expect 3)
removed &-guard occurrences : 0 (expect 0)
on-disk hash now : ca9509b20d611ae383bf33730b29ac0b8a2b4b34 (HEAD blob 23ca638a)
self-test exit: 1 ✗ marks: 11 ✓ marks: 140
✗ a 2>&1 redirection is not a background operator — the reported case
✗ nor is the >&2 form, where the fd number is on the left
✗ nor >&word, which redirects both streams with the & on the RIGHT of >
✗ nor &>word, the same redirection with the & on the LEFT
✗ nor a >&- fd close
✗ nor a <& input duplication, which the > alone would have missed
✗ nor several redirections in one command
✗ and >| is the noclobber REDIRECTION, not a pipeline
✗ the redirect-then-capture shape the gate docs prescribe is certified
✗ and a redirected chain that FAILS still reports the failing exit
✗ os-verify-lock self-test: 10 case(s) failed.
restore: on-disk hash 23ca638a == HEAD blob 23ca638a · git diff HEAD empty · 0 residual markers

All ten certified cases go red; the seven negative-direction cases and every pre-existing case stay green. A "fix" that merely stopped flagging & would have passed the ten while deleting the check they sit beside — which is why both directions are pinned.

An earlier ablation attempt refused to run rather than reporting a reading, because the anchors had moved with the implementation ("MUTATION DID NOT APPLY — this ablation did NOT run; reading is void"). Recorded because a zero-match edit that reports success is the failure mode this check exists for.

Derived gate family, run at f55ee39d under the shared verify lock (VERDICT command-exit 0 · held the lock 29s). Families derived by node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, which reads the change set from git rather than from a hand-written list:

PASS check:nul-bytes OK (scanned 6986 text file(s); no raw ASCII control bytes)
PASS check:agent-test-spelling
PASS check:bash32-floor 20 tracked shell file(s) name no bash 4+ construct
census: 15 constructs checked, floor bash 3.2
PASS check:cli-command-ids 285 command-id literal(s) across 103 file(s) resolve
PASS check:cross-package-test-inputs 20 package(s) read outside themselves, all declared
PASS check:entry-guard 170 scripts/ file(s) — every entry guard goes through invoked-as.mjs
PASS check:parse-guard
PASS check:pnpm-filter-targets 140/177 `--filter` occurrence(s) across 30 file(s) resolve
PASS scripts/check-ci-filter-parity.mjs all 109 declared cross-package glob(s) covered
PASS scripts/check-cross-package-test-inputs.mjs
GATE SUMMARY: 0 failing

check:bash32-floor is quoted at its green state; its red state on the first draft is the section above.

Scope

One file, scripts/pm/os-verify-lock.sh (the scanner and its own st_case suite) — 184 insertions, 3 deletions, no other path touched. No changeset: scripts/pm/** is internal PM-loop tooling and publishes nothing, so this PR carries the skip-changeset label.

Filed while here, out of scope and not fixed in this PR: #12634check-bash32-floor's construct table has the append-both entry but no |& entry, though both operators arrived in bash 4.0. Found because the gate caught one of them in this diff and let the other through.


Generated by Claude Code

@os-litantos-litant added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 27, 2026 — with Claude
@os-litant
os-litant marked this pull request as ready for review August 27, 2026 02:41
@os-litant
os-litant added this pull request to the merge queueAug 27, 2026
Merged via the queue into main with commit 516b213Aug 27, 2026
34 checks passed
@os-litant
os-litant deleted the claude/issue-12518-verify-lock-redirection branch August 27, 2026 03:03
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os-verify-lock.sh reads the & in a 2>&1 redirection as a background operator, downgrading a correctly &&-joined command to batch-last-exit

2 participants

@os-litant@claude