The Session ID Became a Flag: CVE-2026-96620 Turns Claude Agent SDK’s resume Option Into an MCP Server
NVD published CVE-2026-96620 at 20:18:28.293Z on 16 October 2026, source security-advisories@github.com, status Received, scored CVSS:4.0 9.2 (AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N) against pkg:pypi/claude-agent-sdk below 0.2.121, with three CWEs: CWE-20, CWE-78, and CWE-88. The record describes the resume option being handed to the Claude CLI “as a separate token without validation, so a dash-leading value is parsed as an independent CLI flag,” and the consequence as injection of --mcp-config to “define a stdio MCP server, causing arbitrary command execution at CLI startup outside the SDK permission controls.”
That is the record. The more interesting document is the pull request that fixed it — because the Anthropic engineer who wrote the patch spent a paragraph arguing that the severity is lower than what the advisory later published, and because the same PR named an unfixed sibling that has since been closed quietly. The headline finding of this briefing is not the bug. It is that the most precise technical account of CVE-2026-96620 is a merged PR body from 16 July, and it contradicts the CVE on impact.
Two tokens, one of them hostile
The vulnerable construct, read first-hand from the fix commit 347a1cb8 (Qing Wang, 16 July 2026 00:59:36Z), is two lines in SubprocessCLITransport._build_command():
cmd.extend(["--resume", self._options.resume])
cmd.extend(["--session-id", self._options.session_id])
Nothing here is shell-adjacent. These are argv tokens handed to a subprocess spawn, which is normally the correct answer to command injection and is why a reflexive scan of this code finds nothing. The flaw lives one layer out, in how the receiving program declares its options. The PR states the mechanism plainly: the CLI declares -r, --resume [value] with an optional value, because a bare claude --resume is meaningful on its own — it opens the interactive session picker. An option whose value is optional does not bind a --prefixed next token as its value. The parser reads that token as the next flag.
So the SDK’s two-token form converts any attacker-controlled session ID into an attacker-controlled flag. The PR records the observation against the real binary verbatim:
$ claude --resume --version -p hi
2.1.204 # <- ran --version; the "session id" became a flag
$ claude --resume=--version -p hi
Error: ... Provided value "--version" is not a UUID and does not match any session title.
And through the SDK: query(options=ClaudeAgentOptions(resume="--version")) “silently ran claude --version and yielded zero messages.” The escalation sentence is the author’s own: “An injected flag that takes a value (e.g. --settings) escalates this further.” The advisory names the specific escalation that matters — --mcp-config, which declares a stdio MCP server, which is a command the CLI spawns at startup. A permission system that adjudicates tool calls inside a session never sees it, because the MCP server is configured before the session exists.
The precondition is stated identically in every source and deserves equal weight: this requires an application that routes untrusted input into resume. The advisory says “Exploitation required an application to pass untrusted input as the session ID to resume.” That is not exotic. A “resume my session” endpoint taking a session identifier from a request body is the obvious shape of a hosted agent product, and the PR uses exactly that example.
The fix is an equals sign
The patch is seven added lines and two deleted in subprocess_cli.py, with the reasoning committed as a code comment — which is the right place for it, since the next person to “clean up” the inconsistent style would otherwise reintroduce the bug:
# Pass these as --flag=value rather than as two argv tokens. The CLI
# declares --resume with an optional value, so in the two-token form a
# dash-leading value is not bound to the flag and is instead parsed as
# a separate CLI flag -- letting an untrusted value inject arbitrary
# flags. The equals form always binds the value to the flag.
if self._options.resume:
cmd.append(f"--resume={self._options.resume}")
The equals form always binds, so the CLI receives --mcp-config... as a session identifier and rejects it as not a UUID. The remaining 47 of the commit’s 54 added lines are tests, and they assert the right invariant — not just that the new form appears, but that the old one never does: assert "--resume" not in cmd and assert "--session-id" not in cmd, plus cases pinning --resume=--evil and --session-id=-r as single tokens. A regression test that only checks for the presence of the fixed string would pass on a transport that emitted both forms.
--session-id was switched too, and the PR is candid that it was not vulnerable: it “declares a required value and so already bound a dash-leading token, but it is switched too for uniformity and to stay correct if that declaration ever changes.” That is the correct instinct. The vulnerability was never in the SDK’s own code; it was in a property of the CLI’s option declaration that the SDK does not control and is not notified about when it changes.
The disagreement worth recording
Under a heading titled Severity, the PR author writes: “Bounded. This only affects applications that route untrusted input into the resume / session_id options. It is argv-level — one argument per option, no shell is involved, so this is flag injection, not arbitrary command execution.”
The advisory published two months later is titled “Argument Injection via resume Option Allows Arbitrary Command Execution,” rated critical, and the CVE carries CWE-78 — OS command injection — alongside CWE-88 (argument injection) and CWE-20. We do not average these and we do not declare a winner. Both are defensible from different vantage points, and the gap between them is instructive:
- The engineer is right about the mechanism. No shell is involved. One attacker-controlled string becomes exactly one argv token. “Command injection” in the
system()sense never happens, and CWE-78 is a stretch on a strict reading of the code path. - The advisory is right about the outcome.
--mcp-configis the specific flag that converts flag injection into process execution, because the CLI’s own handling of a stdio MCP server is to spawn a command. The attacker needs no shell because the victim program provides the spawn. - The vector is honest where it counts.
AT:P— attack requirements present — is doing the work that the word “Bounded” does in the PR. Both documents encode the same precondition; one encodes it in a metric and the other in prose.
The generalisable lesson sits underneath the dispute. A severity assessment written against the code you changed and a severity assessment written against the capability the bug reaches will systematically disagree, and the second one is the one your users live in. An engineer looking at _build_command() sees argv hygiene. An operator running a hosted agent product sees an endpoint that takes a session ID from a request and can be made to start a process. Both readings are correct; only one of them is actionable at 3 a.m.
The timeline, and the follow-up that did not stay open
Read in order, the dates are not a scandal but they are a measurement:
- 14 July 2026, 01:10:56Z — the TypeScript SDK publishes v0.3.208, whose release notes list, among six bug fixes, “Fixed
extraArgsvalues that look like flags (e.g.resume: '--version') being parsed as their own CLI flags; dash-leading values are now bound with equals-form argv.” No CVE, no advisory, no security heading. It shipped as an ordinary line item in a bugfix list. - 15 July, 02:36:21Z — PR #1123 opened on the Python SDK by
qing-ant, explicitly framed as “the Python half of the issue.” - 16 July, 00:59:37Z — merged, about 22 hours later.
- 17 July, 00:38:23Z — 0.2.121 uploaded to PyPI (the vulnerable 0.2.120 landed 15 July, 23:18:54Z). The release notes do call it by name: “Fixed argv flag injection via
resumeandsession_idoptions.” - 12 September, 08:42:34Z — GHSA-h4mw-j7qp-8mwm published, 57 days after the fix shipped.
- 16 October, 20:18:28Z — CVE-2026-96620 appears in NVD, 34 days after the GHSA and 91 days after the patched release.
The reporter is credited in the advisory text as hackerone.com/moamenmahmood; the structured credits array is empty, which is a small discoverability loss of its own.
Now the part worth following. PR #1123 carries a section headed Known follow-up: extra_args, stating that the same two-token shape (cmd.extend([f"--{flag}", str(value)])) exists there and is “intentionally left alone here — it is not a free swap,” because the equals form breaks flags the CLI declares valueless, breaks string values used to drive boolean flags, and must preserve the bare-flag emission when the value is None. That is an honest deferral with three concrete reasons, which is far better than silence — but it is also a disclosed, unfixed instance of a bug that was being treated as serious enough to patch.
We did not take the follow-up on trust in either direction. Read at main at time of writing, extra_args now carries the guard, applied conditionally so the three objections survive:
elif str(value).startswith("-"):
# In the two-token form, a dash-leading value is not bound
# to its flag when the CLI declares the option with an
# optional value -- it parses as a separate flag instead
# (the same injection the --resume change above closes).
# The equals form always binds. Mirrors the equivalent
# guard in the TypeScript SDK.
cmd.append(f"--{flag}={value}")
The value is None branch still emits a bare flag; everything without a leading dash still uses the two-token form. The narrow fix was the right one, and it closed. Two further hardening measures have landed in the same file since, both aimed at the same class from a different direction: _reject_windows_batch_cli(), which refuses to execute a .bat/.cmd CLI on Windows — the comment cites CVE-2024-27980 (“BatBadBut”) and the per-segment normalisation from Rust’s CVE-2024-24576 fix — and a _reject_windows_cmd_metacharacters() check now applied to resume and session_id before they are joined. The reasoning quoted in the source is exact: subprocess.list2cmdline quotes for MSVCRT argv rules, not for cmd.exe, so “a session title passed to --resume” can reach cmd.exe unescaped. That is the same input field, attacked through a different parser, and the project went looking for it.
This one is actually in your scanner
We have spent most of October documenting advisories that no scanner can see — GHSA ids that 404 against GitHub’s global advisory database, OSV returning nothing for the affected version, ecosystems like other that resolve to no package coordinate. So it is worth recording the opposite result when it happens, measured directly at time of writing:
GET api.github.com/advisories/GHSA-h4mw-j7qp-8mwm→ 200, severity critical, CVE linked.GET api.osv.dev/v1/vulns/GHSA-h4mw-j7qp-8mwm→ 200, aliases["CVE-2026-96620"], published 16 October 19:44:37Z.- An OSV version query for
claude-agent-sdk0.2.120 on PyPI returns the advisory. GET api.osv.dev/v1/vulns/CVE-2026-96620→ 404 — the CVE id alone does not resolve yet, only the GHSA does.
The difference is a real package coordinate. pkg:pypi/claude-agent-sdk appears in lockfiles and SBOMs, so pip-audit, Dependabot, and OSV-scanner will raise this against any pinned install below 0.2.121 without anyone reading a CVE feed. Compare the Junie CLI record, where no version string in the advisory mapped to a published release, or the destructive-command-guard advisory distributed as a release binary under ecosystem other. Being in a package manager is, increasingly, the single largest determinant of whether a fix reaches the people running the code.
Related reading on this site: the three earlier OS command injection CVEs in Claude Code CLI and the Agent SDK, which is the same product line and a stricter version of the same question; the ZCode workspace MCP auto-connect RCE and Junie’s untrusted project MCP config, both cases where an MCP server definition arriving from an untrusted place is the whole exploit; and the eighteen filename command-injection CVEs for the broader family of data-that-becomes-argument bugs.
What to do
Upgrade claude-agent-sdk to 0.2.121 or later — the current release is 0.2.168, so there is no reason to land on the minimum. For the TypeScript SDK the equivalent fix is in 0.3.208. The upgrade is the complete remediation for this record; there is no configuration workaround, because the behaviour is in how argv is constructed.
Then audit the direction of flow, not the version. The question is whether any request-derived value reaches resume, session_id, or extra_args. If your product has a resume-a-conversation route, that route is the one the advisory is about. Validate session identifiers as UUIDs at your own boundary rather than relying on the CLI to reject them — the SDK fix makes the CLI the enforcement point, and you do not control the CLI’s version independently of the SDK’s bundled one.
Treat extra_args as what the PR says it is. It is “by design a ‘pass whatever you want to the CLI’ API,” and the dash-leading guard is a safety net, not an authorisation boundary. A caller who can influence extra_args keys has a broader problem than token shape, and no amount of equals-form joining fixes it.
Generalise the check to every subprocess wrapper you own. The bug class is not “Claude SDK passes resume wrong.” It is: a wrapper emits [flag, value] as two argv tokens, and the target program declares that flag with an optional value. Any SDK, CI shim, or MCP launcher that builds an argv list from caller-supplied strings has the same exposure, and the property that makes it exploitable lives in the other program’s parser — where your own tests and your own type system cannot see it. The defensive form is cheap and universal: join with =, and reject dash-leading values at the boundary where you still know what they are supposed to mean.
Sourcing: CVE id, publication timestamp, vulnStatus, source identifier, CVSS 4.0 score and vector, the three CWEs, affected range, and reference list read first-hand from the NVD 2.0 API. Advisory summary, severity, description text, CWE list, affected package range, reporter credit line, and empty structured-credits array read first-hand from the GitHub repository security-advisory API (GHSA-h4mw-j7qp-8mwm). Fix commit 347a1cb8 author, timestamp, message, file list, diff statistics, and all Python shown read first-hand from the GitHub commit API; PR #1123 metadata and body, including the Severity and Known-follow-up sections, quoted verbatim from the GitHub pull request API. Current extra_args guard, Windows batch-CLI refusal, and cmd.exe metacharacter checks read first-hand from subprocess_cli.py at main. Release timestamps and release-note text for Python v0.2.121 and TypeScript v0.3.208 read first-hand from the GitHub releases API; PyPI upload times for 0.2.120, 0.2.121 and the current 0.2.168 from the PyPI JSON API. Repository metadata (8,291 stars, 1,350 forks, Python, MIT, created 11 June 2025) from the GitHub repository API. GitHub global-advisory and OSV lookups, including the CVE-id 404 and the 0.2.120 version query, measured directly at time of writing. Brave Search used for discovery only; it returned CVE mirrors and automated scoring pages with no independent technical analysis of this record. Nothing installed, no SDK or CLI run, no payload constructed, no MCP server defined or spawned, no third-party request sent beyond reading the cited public records. The claude --resume --version output and the end-to-end SDK behaviour are the PR author’s measurements, quoted as such, not ours.