DEV Community

Artemii Amelin
Artemii Amelin

Posted on

adk web Took a CVSS 10 From a Denylist That Had profile but Not cProfile. Where Our Own Watch-or-Type Line Sits

Google published GHSA-q9cv-5mjc-7cjc yesterday, CVE-2026-79696, against the Agent Development Kit for Python. Critical, CVSS 4.0 score of 10.0, weakness class CWE-184, "Incomplete List of Disallowed Inputs". The description: a code injection in adk web, versions 2.0.0 through 2.6.0, on plain Python, Cloud Run and GKE where pytest is installed, that "allows an unauthenticated remote attacker to execute arbitrary code using a crafted test session replay". Patched in 2.7.0, which shipped on August 13.

The advisory links one commit. It is a small diff, and the commit message explains the whole class of bug better than the CVE text does.

The denylist had profile but not cProfile

ADK agents can be defined in YAML, and a config can name Python code by dotted path in its callback, tool, schema and model fields. The loader resolves that name by importing it. To stop a config from naming os.system, there was a denylist of top-level modules.

Commit a16f6da, dated August 7, says what went wrong: "it had profile but not cProfile, pdb but not bdb, trace, timeit or pydoc. Several of those execute a string you hand them and need no constructor args, so naming one as a tool or callback slipped past both existing mitigations and ran arbitrary code." The tests added with it enumerate what now gets rejected, from cProfile.run to doctest.testmod and py_compile.compile.

The fix stops enumerating. It blocks every name in sys.stdlib_module_names and keeps a hand-written list only for modules that left the standard library but still import: distutils through the setuptools shim, telnetlib, pipes, and CPython's own test package, whose support.script_helper can start a subprocess. The reasoning is that a config should only ever name the agent's own package, google.adk, or an integration, so nothing legitimate lives in the standard library at all.

The commit ends with the sentence that belongs at the top of the advisory: "A denylist still cannot cover third-party packages, which the loader resolves by name, so this narrows the surface rather than closing it."

The server was documented as unauthenticated a week earlier

Two other commits in the same 2.7.0 release belong next to that one. On July 30, commit 76c64ef added docstrings to the API server, the dev server and the adk web command. The API server's reads: "The served endpoints are unauthenticated. Any client that can reach the server can read and write sessions, memory, and artifacts and run agents for any user or app." The dev server's adds that its endpoints "additionally read and write agent files on disk and run evaluation and debugging code." On August 12, commit 8995005 restricted the builder's YAML code references to the app being edited.

Put the three together and the reachable surface was: an HTTP server with no authentication, a dev-only endpoint that runs evaluation code, and a config loader that imports whatever a config names. Only the denylist was pretending to be a boundary. The advisory lists Cloud Run and GKE as affected environments, and that is where "unauthenticated remote attacker" comes from: a development server whose own docstring says localhost, reachable from a network.

People do that because they want to see a running agent from somewhere other than the machine it runs on. That is what shell.online is for, so the same question applies to us: once a browser can reach a running agent, what decides whether it can only watch or also drive?

Where our line is, and which part of it is hard

There are two layers, and the README states the first one plainly. The URL and password together grant access, and "anyone with both can view the terminal and, unless the link is read-only, type with the wrapped process's permissions." A link started with --read-only has browser input rejected server-side. That is the hard boundary. The relay sees connection and lifecycle metadata and ciphertext, not who is behind a browser.

The team web app adds a second layer, and it is worth being exact about what kind of layer it is. Who can open a session is decided by who its password was sealed to. The comment in app/src/lib/session-share.ts puts it this way: "A colleague who was not sealed to holds nothing: not a weaker copy, not a copy the service could hand over, nothing." A member who has never opened the app in a browser has published no public key, so the picker cannot offer them.

Who can type is a separate rule, in app/src/lib/session-view.ts: the session's owner and its assignees, and nobody on a read-only session. The terminal pane drops keystrokes when that check fails and disables stdin in xterm. Read that as what it is: a client-side rule for people who already hold the password. It stops a colleague who was given the password to watch from typing by accident. It does not stop someone holding the raw link and password from opening it outside the app.

The change merged today is that a session can have several assignees instead of one. In PR #89 the assignment route takes a list of member ids, capped at 50, each of which must belong to the organization. Only the session's owner or an admin may change it. The change is written to the audit trail as a handoff event naming who was added, and each added person is notified. The migration adds an assignee_uids text array and backfills it from the old single column, which stays populated with the first assignee so the previous Worker build remains safe to roll back to.

One more thing, because a reader with the repo would find it. What a team member types into a session from the browser is recorded to the accounts service in plaintext. input-log.ts assembles keystrokes into submitted lines (Enter emits a line, Ctrl-C emits an interrupt, escape sequences are dropped), and every member of the team can read and export them. Terminal output is not recorded; it stays inside the end-to-end encrypted stream. The terms of service say this in bold. A comment at the top of app/server/routes/audit.ts still says terminal input is deliberately excluded. That comment is stale and the terms are right.

The audit API reading those records also changed today: server-side pages of 50, at most 100, newest first, filterable by session, actor, kind, free text and a since timestamp, with a total so the page says how many matched rather than how many it fetched.

Write down what is unauthenticated

Across ADK's three commits, the durable fix was the documentation one. A denylist needs a new entry every Python release. A sentence that says "every endpoint here is unauthenticated" changes how the next person deploys the thing.

We hold the Pilot Protocol spec to the same test. The IETF draft gives every node an Ed25519 keypair at registration, uses X25519 for tunnel key agreement and AES-256-GCM for frames, and binds the ephemeral key to the node identity with an Ed25519 signature in the PILA frame. The same section also says, in as many words, that the unauthenticated PILK exchange "provides confidentiality but not authentication" and that a peer who does not answer a key exchange falls back to plaintext. We would rather that sentence sit in the draft, and in the reference implementation, than have someone discover it from a CVE.

Top comments (0)