What
verify._run_git translates three failures that subprocess.run raises before any return code exists — TimeoutExpired (#156), OSError → GitSpawnError (#343), and a strict-decode UnicodeDecodeError (#377). Its docstring states that rationale explicitly: left uncaught, such a failure "would bypass every except GitError guard and crash the run."
There is a fourth in that class it does not translate. If any argv element contains a NUL, subprocess.run raises a plain ValueError: embedded null byte — not an OSError, and not a UnicodeDecodeError (which is a ValueError, but the reverse does not hold, so except UnicodeDecodeError does not catch it). It escapes as itself.
Why it matters
An escaping ValueError behaves worse than a git failure, because it bypasses the graceful arm. In engine._finalize_commit_phase, except verify.GitError escalates the story, but a ValueError falls to except BaseException, which restores the ledger and park record and then re-raises (engine.py:2304-2314). The task is already persisted as COMMITTING by then, so the run crashes and every later bmad-loop resume re-enters the same window and re-crashes identically.
Reachability
The one live path was fixed under #475: {story_title} in scm.commit_message_template put spec-frontmatter text into git commit -m argv, and title: "\0" is an ordinary double-quoted YAML scalar — no exotic file bytes required. That PR now neutralizes C0+DEL at the title chokepoint, with an ablation showing the unpatched path yielding crashed=True, crash_error='ValueError: embedded null byte'.
So this issue is not about a currently-reachable crash. It is that the chokepoint's stated contract — "all pre-return-code failures are translated into the GitError taxonomy" — is incomplete, so the protection depends on every present and future caller sanitizing its own strings. Callers that interpolate agent- or spec-authored text into argv are the risk surface as they accumulate.
Suggested shape
Add a fourth arm to _run_git, alongside the existing three:
except ValueError as exc: # embedded NUL in an argv element
raise GitError(f"git {cmd[3]} refused an invalid argument in {repo}: {exc}") from exc
Ordering matters: it must sit after the UnicodeDecodeError arm, which is a ValueError subclass and carries its own #377 message.
Worth deciding as part of this: whether a distinct type (à la GitSpawnError) is warranted, since "we handed git an unspawnable argument" is a caller bug rather than an environment fault, and the two want different remedies.
Deliberately not folded into #475 — that PR is a feature from an outside contributor, and this touches the shared git chokepoint that every caller funnels through, so it deserves its own review and its own ablation.
What
verify._run_gittranslates three failures thatsubprocess.runraises before any return code exists —TimeoutExpired(#156),OSError→GitSpawnError(#343), and a strict-decodeUnicodeDecodeError(#377). Its docstring states that rationale explicitly: left uncaught, such a failure "would bypass everyexcept GitErrorguard and crash the run."There is a fourth in that class it does not translate. If any argv element contains a NUL,
subprocess.runraises a plainValueError: embedded null byte— not anOSError, and not aUnicodeDecodeError(which is aValueError, but the reverse does not hold, soexcept UnicodeDecodeErrordoes not catch it). It escapes as itself.Why it matters
An escaping
ValueErrorbehaves worse than a git failure, because it bypasses the graceful arm. Inengine._finalize_commit_phase,except verify.GitErrorescalates the story, but aValueErrorfalls toexcept BaseException, which restores the ledger and park record and then re-raises (engine.py:2304-2314). The task is already persisted asCOMMITTINGby then, so the run crashes and every laterbmad-loop resumere-enters the same window and re-crashes identically.Reachability
The one live path was fixed under #475:
{story_title}inscm.commit_message_templateput spec-frontmatter text intogit commit -margv, andtitle: "\0"is an ordinary double-quoted YAML scalar — no exotic file bytes required. That PR now neutralizes C0+DEL at the title chokepoint, with an ablation showing the unpatched path yieldingcrashed=True, crash_error='ValueError: embedded null byte'.So this issue is not about a currently-reachable crash. It is that the chokepoint's stated contract — "all pre-return-code failures are translated into the GitError taxonomy" — is incomplete, so the protection depends on every present and future caller sanitizing its own strings. Callers that interpolate agent- or spec-authored text into argv are the risk surface as they accumulate.
Suggested shape
Add a fourth arm to
_run_git, alongside the existing three:Ordering matters: it must sit after the
UnicodeDecodeErrorarm, which is aValueErrorsubclass and carries its own #377 message.Worth deciding as part of this: whether a distinct type (à la
GitSpawnError) is warranted, since "we handed git an unspawnable argument" is a caller bug rather than an environment fault, and the two want different remedies.Deliberately not folded into #475 — that PR is a feature from an outside contributor, and this touches the shared git chokepoint that every caller funnels through, so it deserves its own review and its own ablation.