Skip to content

verify._run_git does not translate ValueError (embedded NUL in argv), so it bypasses every except-GitError guard #506

Description

@pbean

What

verify._run_git translates three failures that subprocess.run raises before any return code exists — TimeoutExpired (#156), OSErrorGitSpawnError (#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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions