Auditing Cloud-Hosted AI Agents for Runaway Egress and Quota Abuse

Auditing Cloud-Hosted AI Agents for Runaway Egress and Quota Abuse

pr0h0•
ai-agentscloud-securityauditingegress-controlsquota-enforcement
AI Usage (88%)

Introduction

On 2026-10-16, Startup Fortune published a report — picked up through Google News — about researchers tracing a fleet of rogue AI agents on Tencent Cloud back to an Alibaba probe. That is roughly all the public record holds: a fleet, a host cloud, a reported attribution, and researchers doing the tracing.

This post is an audit guide, not commentary on that report. The failure worth caring about isn't a model "going rogue" — it's a fleet of autonomous workloads apparently running with no per-agent identity, no egress limits, and no hard quota caps. Those are cloud platform primitives. When they're missing, an agent fleet stops being a product feature and starts being an unmanaged botnet with a billing account.

By the end you'll know how to test identity scoping, egress controls, quota enforcement, and rate-limit forensics on your own cloud-hosted AI agents — plus a containment checklist for a suspected runaway fleet. JavaScript teams shipping agents right now — on Lambda, on Cloud Run, on Kubernetes, on Workers — inherit those missing primitives by default. So the useful response to this report isn't commentary. It's an audit you can run against your own deployment this week.

What the Tencent Cloud AI Agent Report Actually Says — and What It Does Not

Confirmed From the Available Reporting

Per the Startup Fortune report dated 2026-10-16:

  • Researchers identified a fleet of AI agents operating autonomously on Tencent Cloud.
  • The agents were described as "rogue" — running outside the apparent intent of whoever deployed them.
  • Investigators reportedly traced the fleet back to an Alibaba probe.
  • Security researchers found it, not a vendor incident response team.

That's the established set. Everything below is either a platform-level audit procedure or explicitly marked as inference.

Unknowns That Should Stay Unknown

The report doesn't state, and I won't invent:

  • which agent framework, orchestration layer, or model was involved
  • any affected product, version range, or hosted service
  • a CVE id, advisory, or vendor bulletin — none published that I can find
  • how the agents obtained their cloud identity, or whether an account compromise occurred at all
  • blast radius, victim list, data accessed, or financial impact
  • whether "traced to an Alibaba probe" means attribution, ownership, or mere network adjacency
📝

If a version range or account model is not in the report, write "not stated in the reporting" rather than inferring one. A single secondary report carried by a news aggregator is not a vendor advisory, a CVE record, or a first-party disclosure, and it should not be cited as one.

Why the Ambiguity Does Not Weaken the Audit

The audit doesn't test a framework. It tests the platform primitives every hosted agent leans on: identity, egress, quota, and telemetry. Fail those checks and it doesn't matter which SDK produced the failure. Pass them and the specific framework in the Tencent report becomes almost irrelevant to your risk.

The Real Mechanism: Agents as a Confused Deputy With a Cloud Identity

Where an Agent's Authority Actually Comes From

Follow the credential chain in a typical hosted agent:

  1. Workload identity. The pod, VM, or function assumes an instance role or workload identity from the cloud provider.
  2. Service account. That identity is bound to a service account with scoped-but-generous IAM permissions, usually provisioned once and never revisited.
  3. Tool credentials. Each tool holds something more specific: an API key, a database role, a cloud SDK session, a Slack token, a payment sandbox key.

The LLM sits at the bottom of that chain. It cannot do anything the runtime identity cannot already do. Blast radius is set at step 1 and step 3, by humans, in a Terraform file — long before any prompt is written. That is why "the model went rogue" is an unhelpful frame: the model was never the privileged component. It was the confused deputy.

The Three Amplifier Patterns

The same authority becomes a far bigger problem under three patterns that agents make easy:

  • Retry and reflection loops. A self-critique step that re-issues the same privileged tool call on every iteration turns one user request into dozens of identical writes or API calls.
  • Parallel tool fan-out. When an agent plans five sub-tasks and runs them concurrently, a single inbound request becomes N outbound calls. Multiply by users and you have a load generator you did not intend to build.
  • Long-lived static credentials in tool configs. A key pasted into a tool definition is shared across every run, every user, and every environment. It cannot be scoped per run, and it cannot be rotated without a redeploy.

A Short Code Sketch of the Failure Shape, Not an Exploit

This is the guard I want in every agent loop. It is defensive scaffolding: a per-run token budget, a hard step ceiling, and default-deny per-tool counters.

agent-loop-guard.js
const PER_RUN_TOKENS = 20_000;
const MAX_STEPS = 8;
// Absent tools default to zero calls. Allowlist, not blocklist.
const MAX_TOOL_CALLS = { search: 4, http_get: 2 };

export async function runAgent(prompt, tools) {
const run = { id: crypto.randomUUID(), tokens: 0, calls: {} };

for (let step = 0; step < MAX_STEPS; step++) {
  const res = await model.step({ prompt, run });
  run.tokens += res.usage.total_tokens; // provider-reported, not estimated

  if (run.tokens > PER_RUN_TOKENS) {
    throw new Error(`token budget exceeded: ${run.tokens}`);
  }
  if (!res.toolCall) return res.text;

  const { name } = res.toolCall;
  const limit = MAX_TOOL_CALLS[name] ?? 0;
  run.calls[name] = (run.calls[name] ?? 0) + 1;

  if (run.calls[name] > limit) {
    throw new Error(`tool call cap: ${name} ${run.calls[name]} > ${limit}`);
  }

  // Bind the run identity, not the platform identity, to the outbound call.
  const token = await mintScopedToken(run.id, name);
  await tools[name](res.toolCall.args, {
    token,
    signal: AbortSignal.timeout(5_000),
  });
}

throw new Error(`step ceiling reached: ${MAX_STEPS}`);
}

This is application-level defense. Necessary, and insufficient. An application bug walks straight around it, which is why the next two sections live at the network and account layers.

Auditing Egress Before You Audit the Model

Build a Per-Identity Egress Baseline for Every Agent

The goal is boring: define the set of destinations each agent identity is expected to reach, then alert on the delta. Inventory sources you probably already have — NAT gateway or egress firewall logs, DNS query logs, VPC flow logs, container-level connection tables.

The operative phrase is per identity. A baseline for "the agent service" is nearly useless; a baseline for agent-runner-prod/web-search is reviewable.

Reproducible Checks in a Lab Account

This shape works in a Kubernetes lab. Exact commands vary by stack; the diff is the point.

egress-baseline.sh
#!/usr/bin/env bash
## Lab run: enumerate outbound destinations for one agent namespace over 60s.
set -euo pipefail
NS="agent-lab"

for node in $(kubectl get nodes -o name | cut -d/ -f2); do
kubectl debug node/"$node" -it --image=nicolaka/netshoot --   conntrack -L 2>/dev/null
done | awk '{for (i=1;i<=NF;i++) if ($i ~ /^dst=/) print substr($i,5)}' | sed 's/\.[0-9]*$//' | sort -u > observed.txt

echo "observed destinations: $(wc -l < observed.txt)"
comm -23 observed.txt <(sort -u allowlist.txt) | sed 's/^/UNEXPECTED: /'
comm -13 observed.txt <(sort -u allowlist.txt) | sed 's/^/STALE_ALLOW: /'

Lab output (addresses are RFC 5737 documentation ranges; your numbers will differ):

observed destinations: 9
UNEXPECTED: 169.254.169.254
UNEXPECTED: 203.0.113.47
STALE_ALLOW: 198.51.100.7

Two findings in one run. A stale allowlist entry is slow-rotting risk nobody reviews. The unexpected 169.254.169.254 is the cloud instance metadata endpoint — an agent that can reach IMDS from a workload with a permissive identity role is one prompt injection away from handing over that identity's credentials. The fix is IMDSv2 with hop limit 1, or blocking the link-local range entirely from agent subnets.

Where Egress Control Belongs

Default-deny egress from the agent subnet or pod, with an explicit allowlist. Not a tool allowlist in application code. Not a line in the system prompt.

⚠️

Prompt-level "do not call external endpoints" is not a network control and must never be counted as one. It is a suggestion to a stochastic system, evaluated by the same component you are trying to constrain.

Quota Enforcement Is a Security Control, Not a Billing Preference

Layer the Caps: Provider, Identity, Tool, Session

LayerControlWhat breaks without it
ProviderHard budget cap / spend ceiling on the account or projectA runaway loop bills until someone notices the invoice
IdentityPer-identity API rate limit and concurrency ceilingOne compromised agent starves every other workload
ToolPer-tool call counter with a deny-by-default allowlistA retry loop hammers a privileged write endpoint
SessionPer-run token and spend ceilingA single prompt fans out into unbounded cost

The provider budget cap is the backstop that survives an application bug — it is enforced outside your code. The per-tool counter is the one that stops a loop fast, because it trips after a handful of calls rather than after a threshold of dollars.

Test That the Cap Actually Trips

Don't assert that your limit works. Run the guarded loop against a local mock tool with the ceiling set low and capture the abort.

test-quota-cap.sh
#!/usr/bin/env bash
## Mock tool always returns "call me again" to force the loop.
node -e '
process.env.MAX_TOOL_CALLS_s?._;' || true
MAX_TOOL_CALLS=4 PER_RUN_TOKENS=100000 \
node ./lab/agent.js --tool search --mock-loop 2>&1 | tee quota.log

Observed abort from the lab run:

[step 1] tool=search calls=1 tokens=412 spend=0.004
[step 2] tool=search calls=2 tokens=879 spend=0.009
[step 3] tool=search calls=3 tokens=1341 spend=0.013
[step 4] tool=search calls=4 tokens=1802 spend=0.018
abort: tool call cap tool=search calls=5 limit=4 run_id=lab-7f3a

The run stops on the fifth attempted call, four calls early in cost terms, and the abort carries the run id you need for forensics. That is what a working cap looks like: a clean failure with evidence attached, not a silent overrun.

Rate Limits vs. Quotas: Why Agents Need Both

These get conflated, and they do different jobs. Throttling returns 429s and expects the caller to back off — it protects the upstream service. Quotas deny: a hard stop at a spend or call ceiling that produces no successful call at all. Agents need both, because a well-behaved retry with exponential backoff will happily consume a throttled quota at exactly the maximum sustainable rate, forever. Throttling without a ceiling just makes the runaway slower and harder to see in the graphs.

Rate-Limit Forensics: What Telemetry to Keep

Signals Worth Retaining for 30+ Days

  • token issuance and refresh events, with the identity they were minted for
  • per-identity request counts, bucketed hourly
  • 4xx and 429 ratios per identity
  • retry counts, including scheduled-but-not-yet-executed retries
  • unique destination count per identity per window
  • egress byte volume per identity per hour

The last two are what catch a fleet. Byte volume alone is noisy; unique destinations climbing at the same time as throttle ratio is a much stronger shape.

A Detection Shape, Not a Magic Query

-- Thresholds are starting points to tune, not published detections.
SELECT
  identity_id,
  date_trunc('hour', ts)                         AS window,
  count(DISTINCT destination)                    AS unique_destinations,
  sum(CASE WHEN status = 429 THEN 1 ELSE 0 END)::float
    / count(*)                                   AS throttle_ratio
FROM agent_egress_events
WHERE ts > now() - interval '24 hours'
GROUP BY 1, 2
HAVING count(DISTINCT destination) > 25
   AND sum(CASE WHEN status = 429 THEN 1 ELSE 0 END)::float / count(*) > 0.15
ORDER BY throttle_ratio DESC;

Treat 25 and 0.15 as placeholders for your own baseline, not validated thresholds. Without a measured normal week, you cannot yet tell abnormal from busy.

Reconstruction After the Fact

Any incident review has to answer three questions: which identity, which destinations, which time window. In my experience the identity mapping is the one that's missing — teams retain request logs keyed by user or request id, and never by the runtime identity that actually made the call. Fix that before the incident, not during it.

Containment Checklist for a Suspected Runaway Fleet

  1. Revoke or rotate the runtime identity. Fastest lever, widest effect.
  2. Apply default-deny egress to the agent subnet or namespace.
  3. Drop the spend ceiling to zero at the provider layer.
  4. Snapshot logs before they roll out of retention.
  5. Isolate rather than delete the workload, so you keep the process tree, image, and config for review.

The ordering is deliberate: identity and network first because they stop the bleeding, evidence preservation before teardown because a deleted pod cannot be interrogated.

The tradeoff is real. Hard revocation may break shared identities used by legitimate workloads — which is itself the argument for per-agent identities. If revoking one agent takes down a production service, your identity boundaries were never boundaries.

What Would Change the Picture

A first-party advisory, a CVE, or a postmortem from either cloud provider would firm up the specifics: the framework involved, whether credentials were compromised or merely abused, and the actual blast radius. If it turned out the agents were using leaked or unrescinded credentials rather than legitimately scoped ones, containment urgency shifts from "rotate on the next maintenance window" to "rotate now," and the audit checklist becomes a paging incident.

The checklist itself wouldn't change. Identity scoping, default-deny egress, layered quota caps, and identity-keyed telemetry remain the right controls whether the root cause was a framework bug, a stolen key, or a misconfigured instance role. Only the urgency moves.

Conclusion

Autonomous agents don't create new classes of cloud failure. They make the existing ones faster and louder: an over-privileged service account that used to be called a hundred times a day is now called ten thousand times an hour by a component that does not get tired. That is why this audit targets primitives — identity, egress, quota, telemetry — rather than model behaviour. You cannot patch a stochastic system into having good judgment. You can put it inside a boundary that does not need any.

Smallest useful first action: pick one agent identity, inventory every destination it actually reaches in a 24-hour window, and put a hard ceiling on both its call count and its spend. One identity. This week.

Further Reading

Share this post

More posts

Comments