Skip to content

Validate the SDK release version and stop interpolating it into shell#1121

Open
qing-ant wants to merge 1 commit into
mainfrom
qing/validate-sdk-version
Open

Validate the SDK release version and stop interpolating it into shell#1121
qing-ant wants to merge 1 commit into
mainfrom
qing/validate-sdk-version

Conversation

@qing-ant

Copy link
Copy Markdown
Contributor

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 main on 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.sub replacement strings that rewrite pyproject.toml and src/claude_agent_sdk/_version.py. Two distinct hazards:

  • Quote breakout. version = "{v}" escapes its literal the moment v contains a double quote. A version of 0.0.0"\nimport os; os.system(...)\n# turns _version.py into a source file that executes on import.
  • Backslash expansion in the replacement. re.sub(pat, repl, s) with a string repl does not insert it literally — it runs it through escape processing first, so a \1 or \g<0> in the version is expanded against the match (and a lone \ raises). This is the exact bug 118bf7c fixed 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 a run: block. A ${{ }} expansion is substituted before bash ever sees the line, so a crafted version input becomes shell.

Fixes

  • Validate before use. The version is checked against an allowlist before it is used anywhere. Rejection is a clean non-zero exit with a one-line stderr message and no traceback, and no file is modified.
  • Callable re.sub replacement. re.sub inserts 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 with json.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 var, not command text. The version reaches the build step via 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 a run: block. The publish job already did the right thing.

The allowlist, and why

[0-9]+\.[0-9]+\.[0-9]+(?:(?:a|b|rc)[0-9]+)?(?:\.post[0-9]+)?(?:\.dev[0-9]+)?

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 future 0.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 with fullmatch() against a deliberately unanchored pattern — with ^...$, a later swap to match() would silently accept a trailing newline; unanchored, that swap accepts 0.2.119; id and 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:

  • quote-breakout, shell-metacharacter, control-character and interior-whitespace rejection
  • backslash / backreference payloads (\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 decorative
  • the target files are left untouched on rejection
  • the CLI fails with a clean non-zero exit and no traceback
  • every version the project has actually released still validates — read from the real git tag list, not a hand-copied sample

Also 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 in build_wheel.py/check_pypi_quota.py/download_cli.py — these are pre-existing on main, identical before and after this branch, and in files this PR does not touch.)

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread scripts/update_version.py
Comment on lines +84 to +90
ValueError: If ``version`` is not a fullmatch of VERSION_PATTERN.
"""
candidate = version.strip()

if VERSION_PATTERN.fullmatch(candidate):
return candidate

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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:

  1. build-wheels: build_wheel.py --version "$VERSION"update_version.py strips → validation passes, wheels build as 0.2.120. (A quoted newline is a legal single argv word in bash — not a syntax error.)
  2. publish / Update version files: passes; both files get the clean "0.2.120".
  3. 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.
  4. twine upload: the release is published to PyPI (irreversible).
  5. Commit + Push to main: the version-bump commit is pushed.
  6. Create tag: git tag -a "v0.2.120\n"fatal: not a valid tag name (git's check-ref-format rejects 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.version accepts and normalizes surrounding whitespace (its pattern carries anchored \s*), so pre-PR the same padded space also built, published, and died at the same git tag step. 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.py wrote the literal newline into pyproject.toml's TOML string, which tomllib rejects (Illegal character '\n'), so python -m build failed 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 (adjust test_surrounding_whitespace_is_stripped accordingly); or
  • (b) Have a validation step echo the canonical version to $GITHUB_OUTPUT and make later workflow steps consume that instead of the raw input.

Option (a) is simpler and closes the space-padded pre-existing hazard too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant