PHP2Shell response: an AI agent campaign with Google ADK
A firefighting solution to detect the PHP CVE fast and act: a team of agents that finds every exposed PHP site, ranks it and plans the patch, traced, evaluated and kept within budget.
While we are still fighting through the remediation of the findings from Mythos (Project Glasswing), a new alert affects almost every website: PHP2Shell.
On 14 October, Bank of America's chief executive said the bank had brought in about 1,000 contractors to fix software flaws that AI models exposed, Anthropic's Mythos among them (TNW). A week later, PHP shipped emergency patches for two critical flaws, CVE-2026-109618 and CVE-2026-109619: unauthenticated remote code execution, rated 9.2 (Wiz, The Hacker News). PHP runs 69.2% of the websites whose server-side language is known (W3Techs), and Palo Alto Networks' telemetry shows over 22.6 million exposed instances that are potentially vulnerable (Unit 42).
We need rapid CVE detection, and responding has to keep up. This post builds a team of agents with Google's Agent Development Kit (ADK) that runs the first hours of a PHP2Shell response, and holds it to the bar a real campaign needs.

What you get:
- A working PHP2Shell response team, with the code on GitHub.
- Five agent patterns, each with its campaign rule: routing, shared state, steps in order, fan out and gather, and a review loop.
- How to trace the agents at scale, with the OpenTelemetry spans ADK already emits.
- How to evaluate their behaviour, by checking the path they took, not only the plan they wrote.
- How to keep context and cost in check, so a campaign across a large fleet stays fast and affordable.
PHP2Shell in one minute
- What: two flaws in the PHP interpreter. CVE-2026-109618 is memory corruption during out-of-memory handling; CVE-2026-109619 is a use-after-free involving state kept between requests. Either can allow unauthenticated remote code execution (Tenable).
- Fixed in: PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35, released on 21 October. End-of-life branches get no official release; 7.4 has minimal source backports.
- Urgency: CISA and Canada's Cyber Centre call for immediate patching, and exploitation attempts were reported around disclosure (SecurityWeek). The picture moves fast: on 25 October Unit 42 withdrew its attribution of an intrusion to PHP2Shell, which is one more reason a response plan gets reviewed before it runs. Status as of 26 October.
- The three questions every team asks: are we affected, which sites first, and what's the plan?
The Agent Workflow Architecture
The campaign answers those three questions. An intake agent routes a quick question to an advisory analyst or an asset lookup, and hands a full response to a sequential team: read the advisory, match the fleet's PHP versions in code, analyse exposure and patches in parallel, draft and review the plan in a loop, and save the report. Every tool is read-only, and a person approves each change.

Here is one real run of the agent workflow, replayed from its trace: the route, the version match in code, the two analysts in parallel, one review round, the approval and the saved report.
The code is on GitHub at jimmyaicoach/adk-php2shell-response: the agents, the version matching and plan checks as plain, tested Python, the public advisory facts with their sources, and a sample fleet of twelve sites. I've already run several campaigns like this in production at scale, as Claude Code workflows on my Scan-and-Action Framework (SAAF): see I built an Agent Framework Titan-Kronos.
Five design patterns
1. Route each request to the right agent
intake is the root agent. A question about the advisory goes to advisory_analyst; a question about one site goes to asset_lookup; a full response goes to response_team. The root reads each sub-agent's description and transfers the conversation to the one that fits, and the trace names the agent that answered.

In practice: the route decides what can happen next, so only safe, read-only agents sit behind a model's choice, and anything with consequences sits behind code.
2. Share facts through state
Every agent in the session shares one state. read_advisory saves the advisory; match_versions saves the sites that need action; the analysts save their reports; the planner saves the plan. Each write is an assignment, so ADK records it as an event with its state delta, tied to the call that made it.
In practice: facts come from tools, not from a model's memory, and every change is on record for the post-incident review.
3. Run the response in order
response_team is a SequentialAgent: read the advisory, match the fleet, analyse, plan, save. The order is fixed in code. Before each step, a callback checks that the step before it left its output in state, and stops the run with a clear message if it didn't.
In practice: a step that produced nothing never feeds the next one. The version match is plain Python: a site on 8.4.20 is affected, one on 8.4.27 is patched, and one on 8.1 needs an upgrade.
4. Fan out the analysis
analysis is a ParallelAgent. The exposure analyst ranks each affected site (P1 for internet-facing sites with customer or payment data) and the patch analyst works out each site's target version, both at the same time. Each saves its report under its own key, and the planner gathers both.
In practice: one key per branch, so neither overwrites the other, and every priority traces back to data in the fleet inventory.
5. Loop until the plan passes review
plan_room is a LoopAgent with a cap of three rounds. The planner writes the plan as a table: priority, site, current and target version, owner, rollout and rollback. The reviewer calls check_plan, plain code that lists any site the plan misses, then approves or writes feedback for the next round.
In practice: the loop has two exits. approve_plan ends it, and code refuses that call until check_plan has passed on the current draft. The cap of three rounds is the other exit, and a plan that never passed is saved as NEEDS-REVIEW for a person.
How do you trace an agent at scale?
Export the spans ADK already opens, over OpenTelemetry, and read them per run. ADK's own code opens a span for each invocation, each agent, each model call and each tool call, and stamps the model-call spans with the standard GenAI attributes. A slow tool or a cache miss shows up under the exact call that caused it.
What a model-call span carries:
| Attribute | What it tells you |
|---|---|
gen_ai.system | The provider, set by ADK |
gen_ai.request.model | Which model this call used |
gen_ai.request.top_p, max_tokens | The generation settings sent |
gen_ai.usage.input_tokens, output_tokens | The tokens for this call: the cost of each step |
gen_ai.usage.experimental.reasoning_tokens | Thinking tokens |
gen_ai.usage.experimental.system_instruction_tokens | How much of the prompt is the instruction: the number to watch in the review loop |
gen_ai.response.finish_reasons | A normal stop, or a cut-off |
These follow the OpenTelemetry GenAI semantic conventions, as ADK 1.30.0 emits them, so any backend that understands them reads ADK traces the same way as traces from other agent frameworks.
Where the spans go is chosen at startup, in one of three routes:
- The default: plain
adk webkeeps spans in memory for the Dev UI's Trace tab. - The portable route: set the standard
OTEL_EXPORTER_OTLP_ENDPOINT, and the same spans go to any OpenTelemetry backend. - The Google Cloud route: a flag at startup or deploy sends them to Cloud Trace, with logs to Cloud Logging.
For the trace and trajectory analysis, send them over OTLP to the same backend as the rest of your systems, run the behaviour checks below on every run, and alert on cost per run, failure rate and the instruction's share of the prompt.
How do you evaluate an agent's behaviour on every run?
Check the path, not only the plan. A plan can look complete while the path behind it went wrong: a site matched by the model instead of the code, a plan approved before the check ran, a report saved from an empty plan. Each of these is visible in the trace, so each becomes a deterministic check that runs on every run, with no model in the loop.
| Pattern | Rule | Check on the trace | Catches |
|---|---|---|---|
| Routing | A full response reaches the team | The first transfer for a fleet-wide request goes to response_team | A full response answered by a quick-answer agent |
| State | Facts come from tools | read_advisory ran, and its state delta holds the advisory, before any version is cited | Versions quoted from model memory |
| Steps in order | Code decides "affected" | match_versions ran, and affected_sites exists before the analysts start | A model judging versions on its own |
| Steps in order | Each step builds on the last | Both reports exist before the planner runs, and no step ran after a stop | A step working from nothing |
| Fan out | Both branches ran | Both analyst spans exist, each with its own output key | A branch that failed quietly |
| Loop | Ends for a reason | One to three rounds; an approval follows a passing check_plan on the same draft | An approval with no check behind it |
| Loop | Bounded prompts | The instruction's share of tokens stays flat across rounds | Context growing each round |
| Writes | One bounded write | save_report ran once, and its path is inside reports/ | A write anywhere else |
| Cost | Within budget | Calls, tokens and time under the run's cap | Quota and cost blow-ups |
Run these checks on a fixed set of test fleets, several runs each, against a baseline, and ship a change to the agents only when the path stays clean. The repo's end-to-end test automates nine checks on the trace: the route, the advisory read before versions are matched, code matching before the analysts, both branches, one to three loop rounds, approval only after check_plan passed on that draft, one bounded write, no stopped step, and the affected sites matching the code's answer. In three runs on Gemini 3.8 Flash against the sample fleet, all nine passed every time, with 12 model calls per run, 8 sites needing action, and the plan approved in its first review round. The prompt-size and budget checks in the table are next.
How do you manage context and cost at scale?
Keep each agent's context to what its job needs, and cap every run. Context that grows round after round is the usual reason an agent workflow gets slower and dearer as it goes.
Reading and writing state
- Read it in an instruction with
{key}, or{key?}when the key may be missing, and in code through the tool's or callback's context. - Write by assignment:
state["k"] = v. ADK records each assignment as the event's state delta. - Append by reassigning the whole list:
state["k"] = state.get("k", []) + [x]. Recent ADK versions also record in-place changes, but reassigning works on every version and is checked against the schema. - Save a whole reply with
output_key. The analysts, the planner and the reviewer each use one, so each writes one key and the next round overwrites it. - Scope with prefixes: none for this session,
user:for one user across sessions,app:for everyone, andtemp:for one invocation only. - Write through the context, never to a fetched session's state. ADK's docs warn that writing there bypasses event tracking and can lose data.
Keeping context bounded
- Work from state, not history. Every agent in the response team runs with
include_contents="none": it sees its instruction and the state it needs, not the whole conversation. - Overwrite the draft, keep only the latest feedback. The plan and the feedback are single keys, so the third round's prompt is the same size as the first.
- Set a token budget per run, and decide what the team does at the limit: stop, summarise or hand over to a person.
Caching and quota
- Cache the large, stable part of the prompt, and measure whether it hits: see the scale section below.
- Budget each run. One request fans out into many model calls, so cap steps, tokens and time, retry with back-off and a limit, and count every failure.
At scale: make the cache hit, measure it, evaluate every run
At one fleet, a run is a handful of model calls. At a thousand fleets a day, the same design is a cost line and a quality gate. Three things decide whether it holds up: whether the cache actually hits, whether you can see the hit rate, and whether every change to the agents is evaluated before it ships.
Make the cache hit on purpose
Don't count on the automatic cache for an agent workflow. Gemini caches repeated prompt beginnings on its own, but in my benchmark on Gemini 3.8 Flash, a new question sent as a separate request never hit it, even at 171,000 tokens; follow-up turns in the same conversation did, at up to 98%. Each agent in a workflow sends its own separate requests, so the automatic cache is a bonus, not a plan. Google's own words agree: implicit caching comes with "no cost saving guarantee" (Gemini API docs), and on Vertex AI it starts only at 6,144 tokens for Gemini 3.8 Flash (Vertex AI). When it does hit, cached tokens cost 90% less.
Turn on ADK's explicit context cache. ContextCacheConfig on the App makes ADK create the cache itself, so a hit doesn't depend on the automatic one. It caches the system instruction, the tools and the agent's earlier contents. Three settings control it: min_tokens (skip caching small requests), ttl_seconds (how long a cache lives) and cache_intervals (how many uses before it's refreshed). The first request is never cached: ADK decides from the previous request's token count. It applies to Gemini models only. Google calls explicit caching a guaranteed saving on the tokens that reference a live cache; on Vertex AI it starts at 4,096 tokens for most Gemini 3 models (the page lists no explicit minimum for 3.8 Flash), and a cache lives 60 minutes unless you set otherwise.
Keep the start of every request identical. A cache reuses a prefix, so anything that changes must come after it. Here, each agent keeps its fixed rules in static_instruction, sent first, and only the changing state (the affected sites, the reports, the plan) in instruction, sent after it. Put state in the middle of the system instruction and every call has a new prefix, and the cache never hits.
Cache what is big and stable. Below those minimums nothing is cached at all, so caching pays only when the stable part is large: a response runbook, a patch policy, a long tool catalogue, shared by every call. The agents' own rules are a few hundred tokens, so in my three runs on Gemini 3.8 Flash, each agent's prompt was 440 to 2,450 tokens, below the explicit cache's minimum, and the cache read nothing: 0 of about 8,200 prompt tokens per run. At scale, the runbook is what you cache.
Measure the hit rate
The proof is in the usage block, not a header. Every Gemini response reports prompt_token_count and cached_content_token_count; the hit rate is one divided by the other. The repo's end-to-end test prints it per agent for every run:
| Agent | Model calls | Prompt tokens | Read from cache |
|---|---|---|---|
| intake | 1 | 441 | 0 |
| advisory_reader | 2 | 762 | 0 |
| exposure_matcher | 2 | 581 | 0 |
| exposure_analyst | 1 | 782 | 0 |
| patch_analyst | 1 | 1,406 | 0 |
| planner | 1 | 1,508 | 0 |
| reviewer | 2 | 2,444 | 0 |
| report_writer | 2 | 446 | 0 |
One run of three on Gemini 3.8 Flash, with ADK's explicit cache on: 12 model calls, 8,370 prompt tokens, none cached, because no agent's prompt reached the 4,096-token minimum we set for the explicit cache. The other two runs read the same.
On every run, track it per agent and alert when it drops. A prompt edit that moves state into the fixed part, a model change, or a cache that expired between runs all show up as a falling hit rate before they show up on the bill. Export the spans over OpenTelemetry, record the cached tokens beside the model-call span, and set the alert on the share, not the total.
Evaluate in a dedicated session, every change
Evaluate in its own environment. ADK's evaluation runs each test case in a fresh session, against a fixed set of cases, so one case can't leak state into the next and the campaign's own sessions stay untouched. Keep the evaluation on its own project or session store, with its own sample fleets.
Score the path as well as the answer. ADK scores two things by default: the tool trajectory (did the agent call the expected tools, in the expected order; matched exactly by default, with in-order and any-order options) and the final response against a reference. Here, the nine trajectory checks above run on every end-to-end run, and the build fails if any of them fails.
Measure cache and cost in the end-to-end run, not in the evaluation. adk eval builds its runner from the root agent rather than the App, so the App's cache setting doesn't apply there. Keep cost and hit-rate measurement in the end-to-end runs, and quality in the evaluation.
Run each case several times. A model's path varies between runs, so one pass proves little. Run each case more than once (in ADK 1.30 the Python AgentEvaluator runs each case twice by default) and gate on the worst run, not the average.
Context and caching on Gemini 3.8 Flash
The agents run on Gemini 3.8 Flash, and Google's documentation for it shapes five choices:
- Caching thresholds: the automatic cache starts at 6,144 tokens on Vertex AI (4,096 per the Gemini API), and a cached token costs 90% less.
- Thinking: set with
thinking_level(low, medium or high), not a token budget. - Thought signatures: they must be sent back with function calls, or the call fails with a 400 error.
- Sampling settings: ignored on 3.8 Flash; Google recommends leaving them at the defaults, so the agents set none.
- Prompt order: the question goes after the data, so each request ends with the task line.
The context window is 1,048,576 tokens in and 65,536 out, far above what one run needs; the limit that matters at scale is cost, not size.
Sources: Google's Vertex AI context caching, Gemini API caching, Vertex AI thought signatures, Vertex AI thinking, Gemini 3 guide and the Gemini 3.8 Flash model page, read 2026-10-26.
Designed with production in mind
| Area | Without these design choices | With them |
|---|---|---|
| Facts | The model recalls versions and advisories | Tools read the advisory and the inventory; code decides "affected" |
| Order | Agents hand work along and hope | A sequence with a check before every step |
| Review | One draft, judged by eye | A review loop: code checks coverage, approval only after the check, a cap of three rounds |
| Writes | The model picks a path and writes | One write, to reports/, with the folder and name set in code |
| Tracing | The Dev UI | OpenTelemetry spans exported over OTLP, alerts on cost and failure rate |
| Evaluation | Reading the replies | Nine trajectory checks on every run, against a baseline |
| Context and cost | History grows each round | Agents work from state; a budget per run |
| People | The output goes straight out | Read-only tools; a person approves every patch |
What's next
The agents read a sample inventory and stop at a reviewed plan. Next: connect a real asset inventory through MCP tools, hand an approved plan to a patching agent over A2A with a person's approval in between, and run the trajectory checks on live traces.
Disclaimers
- The agents run against a sample fleet of twelve made-up sites and the public advisory facts. They read and plan; they change no server, and every change still needs a person's approval.
- The advisory facts are from the sources below, read on 26 October 2026; check them against the PHP project's own advisory before acting.
- The trajectory checks and the cache figures come from three end-to-end runs on Gemini 3.8 Flash against the sample fleet, on 26 October 2026; other fleets, prompts and models will differ.
- The OpenTelemetry section was read from ADK 1.30.0's code. The OTLP and Cloud routes were read, not run.
- This post and its code were written with the help of AI agents and reviewed before publishing.
Sources
- PHP2Shell: Wiz, Tenable, Unit 42, CISA, Canadian Centre for Cyber Security AL26-026, The Hacker News and SecurityWeek, read 2026-10-26.
- Bank of America and Mythos: TNW, 15 October 2026. PHP's share of websites: W3Techs, 26 October 2026.
- ADK docs, Session state, LLM agents (output_key), Parallel agents and Context caching, read 2026-10-26.
- Google, Gemini API context caching, read 2026-10-26.
- OpenTelemetry, GenAI semantic conventions, read 2026-10-26.