Layers 1–3 reduce blast radius; they don't eliminate surprise. Layer 4 closes the loop: make agent behavior something you observe, not hope about.
Layer 4 — Detect: audit and respond
What to wire up:
- CloudTrail to Athena / a log sink — queryable by the UA tag from part 2. This is your "what did the agent actually touch" answer.
- Let GuardDuty carry the credential-reuse class first — its dedicated credential-exfiltration findings target EC2 instance-profile credentials specifically, and its anomaly findings may still catch a stolen laptop session replayed from somewhere new. Don't reimplement what it covers; just know the laptop-session case rides on the noisier anomaly detections, which is one more reason the layer-1 short session window matters.
-
Anomaly detection on the dedicated role and source identity first, UA tag second — query the role ARN plus CloudTrail's
userIdentity.sessionContext.sourceIdentity; alert on denied actions, bursts outside working hours, a new Region, or calls from the agent role with no expected app ID. The principal and source identity are the durable attribution — caller-declared, but fixed at assume time and carried through role chaining; the tag is supporting evidence. Expect the missing-tag alert to be noisy until you know which clients in your fleet actually participate in part 2. -
Alert on SCP denials — surprisingly effective. Most services report
AccessDenied, while Amazon EC2 commonly usesClient.UnauthorizedOperation; match the error message too. Cover both explicit-deny messages and the allow-list case where no SCP permits the action. An agent hitting one is either exploring or pushing against a boundary. Both are worth a look. - Run CloudFormation drift detection — neither the SCP nor the UA tag detects configuration drift. Drift detection finds mismatches against the template; CloudTrail and the UA tag can help attribute the API call that caused them.
Two queries to start with, from the Athena table over your CloudTrail bucket:
-- Calls made *as* the agent (session already exists)
SELECT eventtime,
eventsource,
eventname,
useridentity.sessioncontext.sourceidentity
AS source_identity,
useragent
FROM cloudtrail_logs
WHERE useridentity.sessioncontext.sessionissuer.arn
LIKE '%:role/Agent%'
AND eventtime >= '2026-09-01T00:00:00Z'
ORDER BY eventtime DESC
LIMIT 100;
-- Assumes *of* the agent role (bootstrap / layer-1 gaps)
SELECT eventtime,
useridentity.arn AS caller_arn,
json_extract_scalar(requestparameters, '$.roleArn')
AS assumed_role_arn,
json_extract_scalar(requestparameters, '$.sourceIdentity')
AS source_identity,
useragent
FROM cloudtrail_logs
WHERE eventname = 'AssumeRole'
AND json_extract_scalar(requestparameters, '$.roleArn')
LIKE '%:role/Agent%'
AND eventtime >= '2026-09-01T00:00:00Z'
ORDER BY eventtime DESC;
Here's what the first query's output actually looks like against a live account, trimmed to the fields you care about (all four rows are 2026-09-18; eventsource is just the event name plus .amazonaws.com — s3, iam, dynamodb, lambda):
eventtime | eventname | source_identity | useragent
02:11:41Z | GetObject | claude-code-1 | app/ai-agent_eng
02:11:42Z | ListRoles | claude-code-1 | app/ai-agent_eng
03:58:17Z | DescribeTable | (empty) | app/ai-agent_eng
09:15:33Z | InvokeFunction | claude-code-1 | app/ai-agent_eng …
| | | CloudShell
This sample includes GetObject and InvokeFunction, which are CloudTrail data events. A default management-events trail will not show them. Enable data events on the S3 buckets and Lambda functions you actually care about (and on Bedrock agent / async APIs, which are data events too — plain Converse / InvokeModel already arrive as management events), or the "what did it touch" answer is only the control plane.
A few reads to steal from it:
-
source_identityfilled + tag present (rows 1–2): the expected agent path — hardened assume from part 1, tagged from part 2. Quiet. -
source_identityempty but tagged (row 3): the call carried the app ID but not a source identity — the assume path didn't go through the hardened bridge from part 1. A gap in your own layer 1, worth a follow-up. - Tag present but the calling client is CloudShell, not the agent (row 4): the app ID rode in, but the actual caller is something else — check whether your MDM config leaked into a non-agent tool, or an agent is wrapping a different toolchain. The User-Agent shows the app ID prefix plus the real client.
For the bootstrap story, run the second query and read sourceIdentity / userAgent on that event. A successful hardened assume shows the source identity in requestParameters.sourceIdentity; empty there means the caller didn't set it, which is a layer-1 gap.
Two traps before you trust this query, and both bit me live.
First, the schema. A stock Athena cloudtrail_logs DDL doesn't declare the source-identity field inside the session-context struct — add sourceidentity:STRING there first or the column won't resolve. And for an assumed-role call, the principal ARN on the event is the session ARN, not the role: the role you're hunting lives one level down in the session issuer field, which is what the WHERE clause above matches. Adjust the table name and time range to your setup — a partitioned table takes region/year/month/day filters instead.
Second, the pattern. LIKE '%:role/Agent%' is greedy — it matches every role with "Agent" in its name, so on a multi-role fleet you'll pull rows you don't intend to attribute. Narrow the pattern to your actual role names, or reserve a dedicated prefix for agent roles, or you'll chase ghost rows.
Then read the gaps. Rows with an empty source identity did not come through the hardened assume path from part 1, and rows missing your app ID came from a client that doesn't participate in part 2. Those gaps are usually where the story is.
Next up: putting the four layers together — the model, the rollout order, and the parts that are still on you.
Sources
- Amazon Athena — Create a table for CloudTrail logs (stock DDL, partitions)
- AWS CloudTrail —
userIdentityelement - AWS CloudTrail — Enrich CloudTrail events with IAM global condition keys (CloudTrail Lake event data stores only)
- AWS CloudFormation — Detect drift on an entire stack
- Amazon EC2 — Error codes (
Client.UnauthorizedOperation)

Top comments (0)