scripts/pm/os-verify-lock.sh's certifiability scanner treats the & in a 2>&1redirection as a background operator, so a correctly &&-joined command is downgraded from VERDICT command-exit to VERDICT batch-last-exit with the "⚠ THIS COMMAND'S EXIT CODE CANNOT CERTIFY IT" banner.
Found incidentally while running the derived gate union for #11969 (ADR conversion); unrelated to that card's surface, filed rather than fixed.
Measured, isolated to one variable
Same joiner, same trivial parts, run in this container against the real script:
$ bash scripts/pm/os-verify-lock.sh -c 'true && true'
os-verify-lock: VERDICT command-exit 0 · held the lock 0s · waited 59s
$ 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: 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
The only difference between the two is the 2>&1. Nothing is backgrounded in either.
Mechanism
The scanner's '&' case reads exactly one character ahead:
'&')
# `&&` is the one joiner that certifies, so it is consumed, not flagged.
nx="${s:i+1:1}"if [[ "$nx"=='&' ]];then
i=$((i +2))continuefi((depth ==0))&& {
CERTIFIABLE_NOTE="a part of it is backgrounded with '&'"return 1
}
;;In 2>&1 the & is preceded by > and followed by 1, so nx is 1, the && branch is not taken, and it falls through to the backgrounding flag. There is no case for the >& / >>& / N>&M redirection forms. The look-ahead is the wrong direction for this token: what disambiguates it is the character before the &, not the one after.
Why it is worth fixing rather than tolerating
- The remedy it prints is already satisfied, so an agent cannot act on it. The banner says "join the parts with '&&' instead and the verdict certifies every one of them" — the command is
&&-joined. Following the advice cannot clear the warning, which is the shape that teaches agents to ignore the banner generally, including on the real ;/|/& cases where it is correct. - It misfires on the pattern this repo's own gate docs recommend. Several gates print "piping this gate reports the PIPE's status … use redirect-then-capture" precisely to avoid reading
tail's exit code. cmd > out.log 2>&1 && next is that recommended form, and it is exactly the form that trips this. - It degrades a true positive into noise.
batch-last-exit is documented as the honest label for an uncertifiable command; applying it to a certifiable one inverts the signal the label exists to carry.
Same family as a closed precedent
#10570 — guard-main-checkout-bash.sh read a > inside a shell comment as a redirect and false-blocked the command. Same root shape: a single-character shell-token scan that does not model the surrounding context. Different script, so not a duplicate, but the fix and the self-test shape should probably rhyme.
Suggested shape (not prescribing)
Consume the redirection forms before the background check — on seeing &, look back for an unescaped > (optionally preceded by a digit or >), and treat that as a redirect rather than a joiner. scripts/pm/os-verify-lock.sh already carries an st_case self-test suite covering the ;, |, || and & verdicts (around the VERDICT batch-last-exit cases), so a regression pin belongs there: assert command-exit for true > /dev/null 2>&1 && true and that a genuine sleep 1 & still earns batch-last-exit.
Filed unassigned for triage; no fix attempted here.
Generated by Claude Code
Generated by Claude Code
scripts/pm/os-verify-lock.sh's certifiability scanner treats the&in a2>&1redirection as a background operator, so a correctly&&-joined command is downgraded fromVERDICT command-exittoVERDICT batch-last-exitwith the "⚠ THIS COMMAND'S EXIT CODE CANNOT CERTIFY IT" banner.Found incidentally while running the derived gate union for #11969 (ADR conversion); unrelated to that card's surface, filed rather than fixed.
Measured, isolated to one variable
Same joiner, same trivial parts, run in this container against the real script:
The only difference between the two is the
2>&1. Nothing is backgrounded in either.Mechanism
The scanner's
'&'case reads exactly one character ahead:In
2>&1the&is preceded by>and followed by1, sonxis1, the&&branch is not taken, and it falls through to the backgrounding flag. There is no case for the>&/>>&/N>&Mredirection forms. The look-ahead is the wrong direction for this token: what disambiguates it is the character before the&, not the one after.Why it is worth fixing rather than tolerating
&&-joined. Following the advice cannot clear the warning, which is the shape that teaches agents to ignore the banner generally, including on the real;/|/&cases where it is correct.tail's exit code.cmd > out.log 2>&1 && nextis that recommended form, and it is exactly the form that trips this.batch-last-exitis documented as the honest label for an uncertifiable command; applying it to a certifiable one inverts the signal the label exists to carry.Same family as a closed precedent
#10570 —
guard-main-checkout-bash.shread a>inside a shell comment as a redirect and false-blocked the command. Same root shape: a single-character shell-token scan that does not model the surrounding context. Different script, so not a duplicate, but the fix and the self-test shape should probably rhyme.Suggested shape (not prescribing)
Consume the redirection forms before the background check — on seeing
&, look back for an unescaped>(optionally preceded by a digit or>), and treat that as a redirect rather than a joiner.scripts/pm/os-verify-lock.shalready carries anst_caseself-test suite covering the;,|,||and&verdicts (around theVERDICT batch-last-exitcases), so a regression pin belongs there: assertcommand-exitfortrue > /dev/null 2>&1 && trueand that a genuinesleep 1 &still earnsbatch-last-exit.Filed unassigned for triage; no fix attempted here.
Generated by Claude Code
Generated by Claude Code