CVE-2026-96620 is a critical bug in the Claude Agent SDK for Python, and it comes down to one argument. The SDK passed a session ID to the Claude CLI as two separate argv entries. Because the CLI's --resume flag takes an optional value, a session ID that starts with a dash wasn't read as the value at all. The CLI parsed it as a flag of its own. The advisory shows where that leads: an --mcp-config flag that starts an attacker's MCP server, and with it arbitrary commands, outside the SDK's permission controls.
The fix shipped in July. GitHub's advisory database added it on 16 October. This post is for teams that build agents on the Claude Agent SDK in Python or TypeScript, and for anyone who builds command lines for a CLI from code. It covers what went wrong, a parser demo of the exact mistake, what we found in the TypeScript SDK's published builds, and a script that checks a project's versions and call sites.
The short answer
Upgrade claude-agent-sdk for Python to 0.2.121 or later, released on 17 July 2026. Versions before it pass resume to the CLI as ["--resume", value], so an application that lets a user choose which session to resume can be made to run commands. The TypeScript package, @anthropic-ai/claude-agent-sdk, has no advisory, but builds before 0.3.208 used the same two-token form for --resume, and builds before 0.3.212 used it for --session-id. Use 0.3.212 or later. Whatever the version, session IDs are UUIDs: validate any session ID that comes from a request before it reaches the SDK.
What the advisory says
GHSA-h4mw-j7qp-8mwm rates the bug critical, with a CVSS 4.0 score of 9.2. In the advisory's words, the SDK "passed the resume option to the Claude CLI without validation, allowing a value beginning with - to be interpreted as a separate CLI flag". An injected --mcp-config flag defining a stdio MCP server gave "arbitrary command execution at CLI startup that bypassed the SDK's permission controls".
The condition is narrow but realistic: the application has to pass untrusted input as the session ID. A chat backend that lets the browser send back the session to continue does exactly that. The advisory credits HackerOne researcher moamenmahmood.
The timeline:
| Date | Event |
|---|---|
| 16 July 2026 | Fix commit 347a1cb (pull request #1123) |
| 17 July 2026 | claude-agent-sdk 0.2.121 released |
| 12 September 2026 | Repository advisory published, citing CVE-2026-96620 |
| 16 October 2026 | CVE record published on CVE.org |
| 16 October 2026 | Added to GitHub's reviewed advisory database |
So the fix was public for three months before the reviewed database, which Dependabot and many scanners read, could flag older versions, and the CVE record itself appeared the same day.
Why one argv entry matters
The fix commit explains the cause. The CLI declares the option as -r, --resume [value], with the value optional, because a bare claude --resume opens an interactive session picker. An option whose value is optional doesn't take the next token as its value when that token starts with a dash. It treats the token as the next flag.
That isn't a quirk of one library. This demo asks two common parsers the same question, using a placeholder in place of a real MCP configuration. It only parses arguments and doesn't start anything:
// How an option with an optional value treats a dash-leading next token.
// Parsing only: nothing here starts a process or reads a file.
import { Command } from "commander";
function parse(argv) {
const program = new Command()
.exitOverride()
.option("-r, --resume [value]", "resume a session, or pick one if no value")
.option("--mcp-config <file>", "load MCP servers from a file");
program.parse(argv, { from: "user" });
return JSON.stringify(program.opts());
}
// The only thing the attacker controls is the "session ID" string.
const sessionId = "--mcp-config=attacker.json";
console.log("two tokens: ", parse(["--resume", sessionId]));
console.log("equals form:", parse([`--resume=${sessionId}`]));
console.log("real ID: ", parse(["--resume", "8f8b1c0e-2b1e-4a3f-9c2d-5e6f7a8b9c0d"]));
# The same question with Python's argparse. Parsing only.
import argparse
def parse(argv):
p = argparse.ArgumentParser()
p.add_argument("-r", "--resume", nargs="?", const=True)
p.add_argument("--mcp-config")
return vars(p.parse_args(argv))
session_id = "--mcp-config=attacker.json"
print("two tokens: ", parse(["--resume", session_id]))
print("equals form:", parse([f"--resume={session_id}"]))
With commander 14.0.3 and Python 3.14's argparse:
$ node parse_demo.mjs
two tokens: {"resume":true,"mcpConfig":"attacker.json"}
equals form: {"resume":"--mcp-config=attacker.json"}
real ID: {"resume":"8f8b1c0e-2b1e-4a3f-9c2d-5e6f7a8b9c0d"}
$ python3 parse_demo.py
two tokens: {'resume': True, 'mcp_config': 'attacker.json'}
equals form: {'resume': '--mcp-config=attacker.json', 'mcp_config': None}
In the two-token form, the attacker's session ID turns --resume into a bare flag and sets --mcp-config instead. In the equals form, the whole string stays bound to --resume, where the CLI rejects it as an invalid session ID.
The fix in 0.2.121 is that change and nothing more:
if self._options.resume:
cmd.append(f"--resume={self._options.resume}")
if self._options.session_id:
cmd.append(f"--session-id={self._options.session_id}")
What we found in the TypeScript SDK
The advisory covers only the Python package. We downloaded the published builds of @anthropic-ai/claude-agent-sdk from npm and searched the bundled sdk.mjs for how it builds those two flags:
| Versions | --resume | --session-id |
|---|---|---|
| 0.3.197 to 0.3.207 | two tokens, push("--resume", value) | two tokens |
| 0.3.208 to 0.3.211 (from 13 July 2026) | --resume=value | two tokens |
| 0.3.212 and later (from 16 July 2026) | --resume=value | --session-id=value |
The TypeScript SDK changed in the same week as the Python fix. We didn't test older TypeScript builds against the CLI, so we can't say whether they were exploitable the same way. But they built the same command line the Python advisory describes, they drive the same CLI, and no advisory lists them, so no scanner will report them. The current release, 0.3.302, uses the equals form for both.
Check your project
This script walks a project directory. It reads Python requirement files and lockfiles for claude-agent-sdk before 0.2.121, and npm, pnpm and yarn lockfiles for @anthropic-ai/claude-agent-sdk before 0.3.212. It also lists places in code that pass resume or a session ID from a variable rather than a literal, which are the call sites to review. It reads files only and uses the Python standard library.
#!/usr/bin/env python3
"""Find Claude Agent SDK versions and call sites exposed to CVE-2026-96620
(flag injection through the resume and session ID options).
Usage: python3 agent_sdk_resume_check.py [path ...]
Reads files only. Standard library only. Exit status 1 if anything is flagged.
"""
import json
import os
import re
import sys
PY_FIX = (0, 2, 121) # claude-agent-sdk, GHSA-h4mw-j7qp-8mwm
TS_RESUME_FIX = (0, 3, 208) # @anthropic-ai/claude-agent-sdk: first --resume=value build
TS_SID_FIX = (0, 3, 212) # first --session-id=value build
SKIP = {".git", "node_modules", ".venv", "venv", "__pycache__", "dist", "build"}
PY_OPT = re.compile(r"\b(resume|session_id)\s*=\s*([^,\n]+)")
TS_OPT = re.compile(r"\b(resume|sessionId)\s*:\s*([^,}\n]+)")
LITERAL = re.compile(r"""^\s*(None|null|undefined|["'][0-9a-fA-F-]{36}["'])\s*$""")
def ver(s):
m = re.match(r"v?(\d+)\.(\d+)\.(\d+)", s.strip())
return tuple(int(x) for x in m.groups()) if m else None
def py_versions(path, text):
found = re.findall(r"claude[-_]agent[-_]sdk\s*[=~<>!]=\s*([0-9][\w.]*)", text, re.I)
found += re.findall(r'name = "claude-agent-sdk"\s*\nversion = "([^"]+)"', text)
for v in found:
t = ver(v)
if t and t < PY_FIX:
yield f"claude-agent-sdk {v}: affected by CVE-2026-96620, upgrade to 0.2.121+"
def ts_versions(path, text):
vs = []
if path.endswith("package-lock.json"):
try:
pk = json.loads(text).get("packages", {})
except ValueError:
pk = {}
vs = [m.get("version", "") for k, m in pk.items()
if k.endswith("node_modules/@anthropic-ai/claude-agent-sdk")]
else:
vs = re.findall(r"@anthropic-ai/claude-agent-sdk@\D?(\d+\.\d+\.\d+)", text)
vs += re.findall(r'@anthropic-ai/claude-agent-sdk@[^\n]*:\n\s+version "?(\d+\.\d+\.\d+)', text)
for v in sorted(set(vs)):
t = ver(v)
if t and t < TS_RESUME_FIX:
yield (f"@anthropic-ai/claude-agent-sdk {v}: passes --resume as two argv tokens "
"(the pattern behind CVE-2026-96620; no npm advisory), use 0.3.212+")
elif t and t < TS_SID_FIX:
yield f"@anthropic-ai/claude-agent-sdk {v}: --session-id still two tokens, use 0.3.212+"
def call_sites(path, text):
if path.endswith(".py"):
if "claude_agent_sdk" not in text:
return
pat = PY_OPT
else:
if "@anthropic-ai/claude-agent-sdk" not in text:
return
pat = TS_OPT
for n, line in enumerate(text.splitlines(), 1):
for name, value in pat.findall(line):
if not LITERAL.match(value):
yield (f"line {n}: {name} comes from {value.strip()[:40]!r}; "
"make sure it can't be attacker-controlled, or validate it as a UUID")
def main(argv):
hits = 0
for root in argv or ["."]:
for d, dirs, files in os.walk(root):
dirs[:] = [x for x in dirs if x not in SKIP]
for f in files:
p = os.path.join(d, f)
checks = []
if f.startswith("requirements") or f in ("pyproject.toml", "poetry.lock", "uv.lock", "Pipfile.lock"):
checks.append(py_versions)
if f in ("package-lock.json", "pnpm-lock.yaml", "yarn.lock"):
checks.append(ts_versions)
if f.endswith((".py", ".ts", ".js", ".mjs")):
checks.append(call_sites)
if not checks:
continue
text = open(p, encoding="utf-8", errors="replace").read()
for check in checks:
for msg in check(p, text):
print(f"{p}: {msg}")
hits += 1
if not hits:
print("nothing found")
return 1 if hits else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
On a test tree with a FastAPI support bot that resumes whatever session the request names, and a TypeScript agent on an older SDK build:
$ python3 agent_sdk_resume_check.py fx
fx/support-bot/requirements.txt: claude-agent-sdk 0.2.118: affected by CVE-2026-96620, upgrade to 0.2.121+
fx/support-bot/app.py: line 9: resume comes from 'body.get("session")'; make sure it can't be attacker-controlled, or validate it as a UUID
fx/web-agent/agent.ts: line 4: resume comes from 'req.sessionId'; make sure it can't be attacker-controlled, or validate it as a UUID
fx/web-agent/package-lock.json: @anthropic-ai/claude-agent-sdk 0.3.201: passes --resume as two argv tokens (the pattern behind CVE-2026-96620; no npm advisory), use 0.3.212+
The call-site check is a line-level text match, so it flags every non-literal value, including safe ones. Its job is to give you the list to read, not to decide.
What to do
-
Upgrade. Python:
pip install -U "claude-agent-sdk>=0.2.121". TypeScript:@anthropic-ai/claude-agent-sdk0.3.212 or later. Check the lockfile, not the manifest. -
Validate session IDs at the edge. The SDK's own documentation says a custom session ID "Must be a valid UUID", and it describes session identifiers as UUIDs. So reject anything else before it reaches the SDK:
import uuid def session_or_none(value): try: return str(uuid.UUID(value)) except (TypeError, ValueError): return None -
Don't trust the client with session choice. Map sessions to the authenticated user on the server, and resume only sessions that user owns. A valid UUID from someone else is still someone else's conversation.
-
Treat MCP configuration as code. Anything that can add an MCP server can run a process. Our Claude Code security guide covers locking down which servers an agent can load.
-
Audit your own command builders. Search for
[flag, value]pairs built from external input in any code that shells out, especially for options with optional values. The equals form, or a--separator where the CLI supports it, closes the gap.
The fair view
Exploitation needed an application that passed user input straight into resume. An agent that generates session IDs on the server and never accepts one from the client wasn't exposed. Both SDKs changed within days of each other, explained the cause plainly in the commit, and credited the reporter. The fix itself is a one-line change per flag.
The pattern is older than AI agents. We covered the same mistake in argument injection in Git and Mercurial: a value that starts with a dash, placed where a parser expects either a value or a flag. Agent SDKs are new wrappers around powerful CLIs, and every option they forward is one more place the old bug can come back. Here it came back in the one option most likely to carry a value from the browser.