Validate the SDK release version and stop interpolating it into shell#1121
Validate the SDK release version and stop interpolating it into shell#1121qing-ant wants to merge 1 commit into
Conversation
The version handed to the release flow was written verbatim into two
source files as a string literal and spliced into a bash command, with
no validation anywhere in between. Two separate injection shapes:
1. scripts/update_version.py took the version from argv unchecked and
f-stringed it into re.sub *replacement* strings that rewrite
pyproject.toml and src/claude_agent_sdk/_version.py. A string
replacement also undergoes backslash-escape processing, so a "\1" or
"\g<0>" in the value is expanded against the match; and a value
containing a double quote escapes the literal it is written into,
turning _version.py into a source file that executes on import.
2. .github/workflows/build-and-publish.yml spliced ${{ inputs.version }}
directly into the command text of a run: block, so a crafted workflow
input became shell.
Fixes:
- Validate the version against an allowlist before it is used anywhere.
Every version this project has released -- all 121 git tags, and all
121 releases on PyPI -- is a plain MAJOR.MINOR.PATCH; the pattern also
admits the PEP 440 pre/post/dev suffixes PyPI would accept, so a future
0.3.0rc1 is not rejected on release day. The admissible character set is
exactly [0-9a-z.]: no quote, backslash, whitespace, or metacharacter.
Rejection is a clean non-zero exit with a one-line stderr message and no
traceback, and leaves both target files untouched.
- Substitute with a *callable* replacement rather than a string, so re.sub
inserts the value literally with no escape pass, and emit the literal
with json.dumps(). Both substitutions are computed before either file is
written, so a missing anchor cannot leave the first file half-rewritten;
a missing anchor now raises instead of silently reporting success.
- Pass the version to the build step through env: and reference it as
"$VERSION", so bash expands it as a single word instead of GitHub
splicing it into the command text.
Adds tests/test_update_version.py: quote-breakout, metacharacter, control
character and whitespace rejection; backslash/backreference payloads
pinning that the callable replacement is literal (including a test that
demonstrates the old string-replacement form expanding "\g<0>"); that no
file is modified on rejection; and that every version the project has
actually released still validates, read from the real git tag list.
:house: Remote-Dev: homespace
There was a problem hiding this comment.
Beyond the inline nit, a few other candidates were examined and ruled out this run: the previous_tag value flowing into the changelog action's prompt (it comes from git describe on trusted refs, not user input); the allowlist newly admitting PEP 440 pre-release versions that auto-release.yml's patch-increment arithmetic can't parse (auto-release only ever reads the plain X.Y.Z on main — a pre-release would arrive via manual dispatch, and the arithmetic's assumption is pre-existing); and test_every_git_tag_validates asserting over all v* tags (it would only fail if a non-release v* tag is ever pushed, and the test is easy to adjust then).
Extended reasoning...
Bugs were found (one nit-level inline comment), so per policy I am not approving; this note records the additional candidates that were investigated and refuted so a later pass doesn't re-explore them from scratch. The PR itself modifies the release/publish pipeline — validation of the version string, callable re.sub replacements, and moving the workflow_dispatch input out of shell command text into env — which is security-relevant CI and appropriate for a human reviewer to sign off on, even though the implementation and its 73-test suite look careful and well-reasoned.
| ValueError: If ``version`` is not a fullmatch of VERSION_PATTERN. | ||
| """ | ||
| candidate = version.strip() | ||
|
|
||
| if VERSION_PATTERN.fullmatch(candidate): | ||
| return candidate | ||
|
|
There was a problem hiding this comment.
🟡 validate_version() strips surrounding whitespace and accepts the padded input, but the stripped value never leaves this script — the workflow's job-level VERSION: ${{ inputs.version }} feeds the raw string to every downstream step, so a version with a trailing newline (possible via an API-dispatched workflow_dispatch) now passes the gate, publishes to PyPI, pushes the bump commit, and only then fails at git tag -a "v$VERSION" — a partial release needing manual repair. Consider requiring version == version.strip() so padded input is rejected at the gate, or having the workflow consume the validated value instead of the raw input.
Extended reasoning...
What happens
validate_version() deliberately strips surrounding whitespace before matching (candidate = version.strip(), pinned by test_surrounding_whitespace_is_stripped). That's fine for the two files this script rewrites — they get the stripped value. But the stripped value never propagates back to the workflow: .github/workflows/build-and-publish.yml sets a job-level env: VERSION: ${{ inputs.version }} and every subsequent step consumes the raw input — the quota pre-flight's data["releases"][version] dict lookup, git commit -m "chore: release v$VERSION", git tag -a "v$VERSION", git push origin "v$VERSION", gh release create, and the changelog awk -v ver="$VERSION" '$2 == ver'.
Concrete walkthrough (trailing newline via API dispatch)
The GitHub UI's single-line input field can't produce a newline, but an API-dispatched workflow_dispatch (or gh workflow run -f version="$(printf '0.2.120\n')") can, since JSON string inputs admit \n. Trace it through:
- build-wheels:
build_wheel.py --version "$VERSION"→update_version.pystrips → validation passes, wheels build as0.2.120. (A quoted newline is a legal single argv word in bash — not a syntax error.) - publish / Update version files: passes; both files get the clean
"0.2.120". - Quota pre-flight: keys
data["releases"]["0.2.120\n"]— a guaranteed miss, so the already-uploaded-file skip silently never fires on a re-run after a partial upload. - twine upload: the release is published to PyPI (irreversible).
- Commit + Push to main: the version-bump commit is pushed.
- Create tag:
git tag -a "v0.2.120\n"→fatal: not a valid tag name(git'scheck-ref-formatrejects any whitespace in a refname; verified, exit 128). The job dies here.
Net result: package on PyPI and bump commit on main, but no tag and no GitHub release — manual repair required. Even if the tag step were reached, the release-notes awk ($2 == ver) would never match the CHANGELOG heading.
Addressing the refutation
One verifier argued this is entirely pre-existing. That's half right, and worth being precise about:
- Trailing space (the realistic paste case): pre-existing, not a regression.
packaging.versionaccepts and normalizes surrounding whitespace (its pattern carries anchored\s*), so pre-PR the same padded space also built, published, and died at the samegit tagstep. The PR changes nothing here. - Trailing newline: a genuine (if exotic) regression. The refutation is correct that the original claim of a "pre-PR bash syntax error" is wrong — a newline inside double quotes is legal bash. But the corrected mechanism, verified independently by two verifiers, still fails early pre-PR: the old unvalidated
update_version.pywrote the literal newline into pyproject.toml's TOML string, whichtomllibrejects (Illegal character '\n'), sopython -m buildfailed in the build-wheels job, before the publish job ran — nothing built, nothing published. Post-PR, the strip launders that input through to the post-publish tag failure at step 6.
So merging as-is doesn't break any realistic dispatch path — hence nit, not blocking — but the strip does widen the accepted input set beyond what the untouched downstream workflow can handle, and it sits oddly against the PR's own stated rationale: "publishing a different string than the caller asked for is worse than failing." The strip is exactly that — the script proceeds on a different string than the caller passed, while the rest of the pipeline keeps the original.
Fix
Either of:
- (a) Drop the strip, or additionally require
version == version.strip(), so padded input is rejected at the gate like every other malformed input — a one-liner that matches the PR's fail-loud philosophy (adjusttest_surrounding_whitespace_is_strippedaccordingly); or - (b) Have a validation step echo the canonical version to
$GITHUB_OUTPUTand make later workflow steps consume that instead of the raw input.
Option (a) is simpler and closes the space-padded pre-existing hazard too.
Follow-up to the deferred review thread on #1117.
#1117 hardened the CLI version flow. A review bot pointed out that the parallel SDK version release flow was untouched and still had both of the same injection shapes. That thread was deliberately deferred to a follow-up — this is it. This PR is independent of #1117 and lands on
mainon its own; it touches none of #1117's files.The two injection shapes
1.
scripts/update_version.py— unvalidated version written into source as a string literal.The version came from argv with no validation, then was f-stringed into
re.subreplacement strings that rewritepyproject.tomlandsrc/claude_agent_sdk/_version.py. Two distinct hazards:version = "{v}"escapes its literal the momentvcontains a double quote. A version of0.0.0"\nimport os; os.system(...)\n#turns_version.pyinto a source file that executes on import.re.sub(pat, repl, s)with a stringrepldoes not insert it literally — it runs it through escape processing first, so a\1or\g<0>in the version is expanded against the match (and a lone\raises). This is the exact bug118bf7cfixed on the CLI side.2.
.github/workflows/build-and-publish.yml:34— input spliced into shell.--version "${{ inputs.version }}"was interpolated into the command text of arun:block. A${{ }}expansion is substituted before bash ever sees the line, so a craftedversioninput becomes shell.Fixes
re.subreplacement.re.subinserts a callable's return value literally, with no escape pass — so\1/\g<0>in the value stay those characters. The literal itself is emitted withjson.dumps(), so quoting is done by a serializer rather than an f-string. Both substitutions are computed before either file is written, so a missing anchor can't leave the first file half-rewritten — and a missing anchor now raises instead of silently reporting success (the old code wrote the file back unchanged and printed "Updated").env:and is referenced as"$VERSION", so bash expands it as a single word. I swept the whole workflow: this was the only raw interpolation in arun:block. Thepublishjob already did the right thing.The allowlist, and why
Every version this project has ever released — all 121 git tags, and all 121 releases on PyPI — is a plain
MAJOR.MINOR.PATCH, with zero exceptions. I checked both before choosing, so this doesn't reject a legitimate version. It additionally admits the PEP 440 pre/post/dev suffixes PyPI would accept for a future0.3.0rc1, so release day isn't the day someone discovers this script rejects a real version.The security property is the character set, not the numeric arity: every admissible character is drawn from
[0-9a-z.]. There is no quote, backslash, whitespace, or shell metacharacter in the admissible set, which is what makes both hazards above unreachable rather than merely unlikely. Matched withfullmatch()against a deliberately unanchored pattern — with^...$, a later swap tomatch()would silently accept a trailing newline; unanchored, that swap accepts0.2.119; idand fails loudly in the tests.Note: two validators, to be unified
#1117 introduces
scripts/_cli_version_validation.py, which is the natural home for this. I can't import from it without depending on an unmerged branch, so the validation here is self-contained. Once #1117 lands, the two should be unified behind one shared validator. The allowlists legitimately differ (a CLI version has-dev.…suffixes and dist-tags; an SDK version has PEP 440 suffixes and must satisfy PyPI), but the validate-then-substitute-with-a-callable shape is common to both. This is called out in the module docstring so it isn't forgotten.Tests
New
tests/test_update_version.py(73 tests), mirroring #1117's CLI-side coverage:\1,\g<0>) proving the callable replacement is literal — including a test that demonstrates the old string-replacement form expanding\g<0>into the match, so the callable is provably load-bearing rather than decorativegit taglist, not a hand-copied sampleAlso verified by driving it end-to-end: the RCE payload is rejected and never executes, and a shell-level simulation confirms the old splice did execute an injected command while the new
env:form keeps the identical payload as one inert argv word.ruff check/ruff format --check/mypy src/clean; full suite 1128 passed, 5 skipped. (mypy scripts/reports 6 errors inbuild_wheel.py/check_pypi_quota.py/download_cli.py— these are pre-existing onmain, identical before and after this branch, and in files this PR does not touch.)