DEV Community

Cover image for Detecting Shadow AI Agents with AWS Agent Registry
Yuuki Yamashita
Yuuki Yamashita

Posted on

Detecting Shadow AI Agents with AWS Agent Registry

AWS Agent Registry (GA August 2026) gives a team a private catalog for AI agents, MCP servers, and tools — semantic search, approval workflows, CloudTrail audit trails. What it doesn't give you out of the box is a way to find the agents that were never registered in the first place.

This is a pattern for closing that gap: scan the AWS account for AgentCore runtimes, diff them against what's actually in the registry, and route anything unregistered through a real approval flow before it becomes a permanent record. I built it as Shadow Agent Hunter; this post is about the Agent Registry API details that made it work (and the ones that didn't, at first).

The three API calls that matter

Agent Registry splits into a control plane (agent-registry-control) for managing registries and records, and a data plane (agent-registry) for searching approved ones.

Finding what's already registered. list_registry_records returns every record regardless of status, so pending/draft records don't get re-flagged on a rescan either:

import boto3

control = boto3.client("agent-registry-control", region_name="us-east-1")
registered_names = set()
next_token = None

while True:
    kwargs = {"registryId": registry_id}
    if next_token:
        kwargs["nextToken"] = next_token
    resp = control.list_registry_records(**kwargs)
    registered_names.update(r["name"] for r in resp["registryRecords"])
    next_token = resp.get("nextToken")
    if not next_token:
        break
Enter fullscreen mode Exit fullscreen mode

I originally reached for the provenance field here, expecting to link records back to their source AgentCore runtime ARN. Don't — create_registry_record rejects a caller-supplied provenance with ValidationException: provenance cannot be set by the caller. It's populated only by the service's own auto-detection/sync integrations, not by a plain API call. Matching on record name is simpler anyway, since names are unique within a registry.

Searching for duplicates. search_discoverable_registry_records does hybrid semantic + keyword search, ordered by relevance:

registry = boto3.client("agent-registry", region_name="us-east-1")
resp = registry.search_discoverable_registry_records(
    searchQuery=f"{runtime_name} {runtime_description}",
    registryIds=[registry_arn],
    maxResults=3,
)
Enter fullscreen mode Exit fullscreen mode

There's no numeric score in the response — just an ordered list. With a small registry (say, two or three approved records), that means it always returns something, even for genuinely unrelated runtimes, because there's nothing better to rank against. This isn't a bug so much as a reminder that semantic search needs a reasonably sized corpus to actually discriminate. It's also a decent argument for keeping a human in the approval loop rather than auto-rejecting on any hit.

Writing the approval. Three sequential calls: create, submit, approve.

created = control.create_registry_record(
    registryId=registry_id,
    name=runtime_name,
    description=description,
    recordType="CUSTOM",
    recordVersion="1.0",
    descriptors={"custom": {"data": json.dumps({"runtimeArn": runtime_arn})}},
)
record_id = created["recordArn"].split("/record/")[1]  # not returned directly

# record starts CREATING and must reach DRAFT before you can submit it
for _ in range(15):
    status = control.get_registry_record(registryId=registry_id, recordId=record_id)["status"]
    if status == "DRAFT":
        break
    time.sleep(1)

control.submit_registry_record_for_approval(registryId=registry_id, recordId=record_id)
control.update_registry_record_status(
    registryId=registry_id, recordId=record_id,
    status="APPROVED", statusReason="Reviewed and approved",
)
Enter fullscreen mode Exit fullscreen mode

Two things worth flagging: CreateRegistryRecordResponse only returns recordArn and status — no recordId field, so you extract it from the ARN — and creation is asynchronous, so a record submitted for approval before it leaves CREATING will fail.

Cross-referencing with AgentCore Runtime and CloudTrail

The other half of "shadow agent" detection is enumerating what's actually running, independent of the registry:

agentcore = boto3.client("bedrock-agentcore-control", region_name="us-east-1")
runtimes = agentcore.list_agent_runtimes()["agentRuntimes"]
Enter fullscreen mode Exit fullscreen mode

The IAM action for this is bedrock-agentcore:ListAgentRuntimes — note the namespace is bedrock-agentcore, not bedrock-agentcore-control like the SDK package name would suggest. Getting this wrong produces a plain AccessDeniedException with no hint about the namespace mismatch.

For attribution — who deployed an unregistered runtime — CloudTrail's LookupEvents over CreateAgentRuntime gets you there, with the usual 90-day retention caveat:

cloudtrail = boto3.client("cloudtrail", region_name="us-east-1")
resp = cloudtrail.lookup_events(
    LookupAttributes=[{"AttributeKey": "EventName", "AttributeValue": "CreateAgentRuntime"}],
)
for event in resp["Events"]:
    detail = json.loads(event["CloudTrailEvent"])
    arn = detail.get("responseElements", {}).get("agentRuntimeArn")
    deployer = detail.get("userIdentity", {}).get("arn")
Enter fullscreen mode Exit fullscreen mode

Infra notes

Agent Registry has no CDK construct as of September 2026 — no AWS::AgentRegistry::Registry CloudFormation resource type exists yet. I provisioned the registry and its seed records with the boto3 script above rather than CDK. AgentCore Runtime, by contrast, has a stable L2 construct (aws_bedrockagentcore.Runtime), and AgentRuntimeArtifact.fromCodeAsset() deploys straight from a local Python directory with no Docker step.

If you're running this on Vercel with OIDC federation to AWS: the OIDC provider is scoped per Vercel team, not per project. A second project under the same team hitting new iam.OpenIdConnectProvider(...) will fail deployment, since an AWS account only accepts one provider per issuer URL. Import the existing one with iam.OpenIdConnectProvider.fromOpenIdConnectProviderArn() instead of creating a second.

Result

Scanning an account with a couple of intentionally-similar and intentionally-unrelated AgentCore runtimes: the similar one surfaces a real "possible duplicate" match against an existing approved record, and approving the unrelated one produces a genuine APPROVED record you can see with list-registry-records afterward — not a mock, the actual governance workflow AWS Agent Registry ships.

Top comments (0)