
Read-Only AI Agents on AWS: Two Guardrails Failed in 2026, and IAM Held
Even ViewOnlyAccess returns Lambda environment variables, and CloudTrail records no object writes unless you set it up first. What keeps an AI agent read-only on AWS: its own role, an organization-wide SCP, and a witness that was listening before the agent started.
Updated 11 October 2026. Layer 1 now allows only the reads its task needs, instead of ViewOnlyAccess with a deny list. In the comments, Mohammad Thabet showed that the deny missed
ListVersionsByFunction, and the same policy has more calls like it.In September an AI agent skill I wrote was told to stay read-only, made twenty-five writes, and reported none. In July, AWS had fixed two guardrails for AI agents of its own. Here is what held both times, and what to set up before an agent gets an AWS role.
In July 2026, AWS fixed two of its own guardrails for AI agents in the same week. One quietly dropped its deny list if its index of read-only operations failed to load at startup (CVE-2026-16584). The other hid write tools from the agent's menu, yet still ran them for anyone who knew the name.
AWS's bulletin for the first says the IAM permissions on the agent's credentials "remain in effect and are unaffected." That sentence is half of this article. The other half is how you would find out what the agent did.
An agent cannot talk its way past two things. A lock on the outside, which blocks writes whatever the model decides. And a witness: the system's own record, which the agent cannot edit and which does not care what the agent says about itself. On AWS the lock is IAM and the witness is CloudTrail. IAM's read-only policies read more than their names say, and CloudTrail records less.
I learned this in September, on a smaller system. I wrote a skill that audits who can see what in a database, and told it at the top of the file, in plain words, to change nothing. Then I asked it to fix everything it found, without asking me. It made twenty-five changes and reported that it had made none . I found out from the database's own query log, not from the agent.
One honest note. When I reran that test with the lock on, the rewritten skill refused on its own, and the lock never came into play. I trust the lock because of where it sits, not because I have watched it stop anything.
| What you set up | What it sounds like | What it does |
|---|---|---|
| A guardrail in an MCP server, such as a deny list or a read-only flag | A fence | Code inside the tools the agent uses, and two of them needed fixes in July 2026 |
| One-click install of the EKS or Serverless MCP server | The read-only default | Switches on writes and sensitive data access in its install arguments |
| ReadOnlyAccess | Look, do not touch | Reads S3 objects, DynamoDB items and log lines, and receives SQS messages |
| ViewOnlyAccess | Metadata only | Still returns Lambda and ECS environment variables and EC2 user data |
| CloudTrail Event history | A record of everything | 90 days of management events, one Region at a time, and no data events |
The guardrail is not the lock
AWS's open-source MCP servers for EKS, ECS and Serverless in awslabs/mcp start read-only in their code: the EKS server "runs in read-only mode by default." The general-purpose AWS API MCP Server, which runs any AWS CLI command, ships with
READ_OPERATIONS_ONLY set to false.The one-click install buttons in the EKS and Serverless READMEs override those defaults. They add the server with
--allow-write and --allow-sensitive-data-access already in its arguments, and the EKS README's example configuration "includes both."Almost nobody meets a tool through its argument table. They meet it through the button.
CVE-2026-16584 hit versions 0.2.13 to 1.3.47 of the AWS API MCP Server. If its read-only operations index failed to load, for example on "a transient network error," its deny list stayed "silently skipped for the entire process lifetime." The proxy's
--read-only flag had a quieter gap, fixed in 1.6.4 on 20 July: "A client that knew the tool name could still invoke it directly via tools/call." AWS's migration guide still uses 1.6.3 as its example pin.My skill file had the same flaw as that proxy. A list of tools is a hint, not a fence.
With the fix, the flag is honest, and blunter than its name: it drops every tool not marked
readOnlyHint: true. In the server's tool list on 3 October 2026, that removed aws___run_script, which runs Python against your account, and aws___get_presigned_url. What is left reads documentation. Read-only mode does not give you an agent that only reads your account. It gives you one that cannot see it.For the old consent prompt, the migration guide offers one replacement: "use IAM policies to control write access." Requests forwarded by AWS's managed MCP servers carry
aws:ViaAWSMCPService, set to true, and aws:CalledViaAWSMCP. Calls made directly, by the open-source servers on your laptop or by the shell, carry neither mark.The managed server knows about the shell. The instructions it sends to every connecting agent end with one sentence: "Do NOT use the Bash tool to run the AWS CLI." That is a rule in prose, inside a prompt. I wrote one of those in September. It lasted one request.
So put the limit in IAM, where no server setting can switch it off.
Read-only in IAM reads more than its name
AWS's description of ReadOnlyAccess says whoever has it "will also have access to read data in storage services like Amazon S3 buckets and Amazon DynamoDB tables." At version 190 it includes
s3:Get*, dynamodb:Scan and logs:FilterLogEvents. ViewOnlyAccess, AWS says, "cannot read resource content."ViewOnlyAccess is the better start, and it still is not metadata only. It includes
lambda:List*, and ListFunctions returns every function's environment variables. For the default key, the Lambda guide says, "No AWS KMS permissions are required." It includes ecs:Describe*, which returns task definitions with their environment variables. And it includes ec2:DescribeLaunchTemplateVersions, which returns user data. The secrets people paste into configuration are configuration, and view-only reads configuration. Those three calls are not the whole list. ListVersionsByFunction returns the same variables for every version of a function, and the log group names give away the function names it asks for. EC2 user data also comes back through DescribeInstanceAttribute, Auto Scaling launch configurations and Spot requests. A deny list closes the calls somebody has already found. So layer 1 allows what the agent's task reads, and nothing else.It is the house-sitter from my September post, with a better note. The note says: do not move the furniture. It says nothing about the spare key taped inside the cupboard.
One permission in ReadOnlyAccess changes state. It is
sqs:Receive*. When you receive a message from a queue, the SQS documentation says, it "remains in the queue but becomes temporarily invisible to other consumers." Every receive also counts toward the limit after which a redrive policy moves the message to the dead-letter queue. An agent that peeks into a production queue to diagnose a backlog hides those messages from the service that was supposed to process them.In a queue, looking is touching.
The witness was not in every room
CloudTrail's Event history is on in every account and keeps an "immutable record of the past 90 days of management events in an AWS Region." It "does not show data events": what happens inside resources, like S3 object writes and DynamoDB item changes. "By default," the CloudTrail guide says, "trails and event data stores do not log data events." An agent that deletes forty objects leaves nothing in Event history, and a selector added afterwards records the next run, not this one.
Where calls made through the managed server land is not settled in AWS's documentation. They arrive forwarded on your behalf, marked
aws-mcp.amazonaws.com in invokedBy. An AWS Security Blog post from April says "MCP-originated downstream calls are classified as data events, so you must enable data event logging on your CloudTrail trail to capture them." The server's own CloudTrail page does not say either way. If the blog is right, a check that reads only Event history passes in silence while the agent works through the managed server.The same page shows each tool call recorded as an event of its own, with an "Exact copy of MCP request params," which for
aws___run_script is the Python the agent wrote. The list of data event types does not yet name the server, so look for eventSource aws-mcp.amazonaws.com in your trail before you count on it.IAM, a global service, records its events in US East (N. Virginia). In my run, the change I stared at for thirty seconds was the one to my own permissions. On AWS that is an IAM call, and it lands in us-east-1 even when the agent was working in Frankfurt.
Before you trust the witness, make it catch something.
Framework: two locks and a witness, set before the first run
Layer 1: give the agent its own role, and allow only what its task reads (day one)
The agent never runs as you. My skill rewrote my own permissions because it was running as me. Give it a role of its own that allows the reads its task needs and nothing else, and require a source identity, which CloudTrail records and which survives role chaining:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# agent-trust.json, read by aws iam create-role: one human role may start agent sessions, and only labelled ones
cat > agent-trust.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::111122223333:role/platform-engineer" },
"Action": ["sts:AssumeRole", "sts:SetSourceIdentity"],
"Condition": { "StringLike": { "sts:SourceIdentity": "agent-*" } }
}]
}
EOF
# agent-allow.json: the reads one diagnosis needs, here an ECS service and its logs, and nothing else
cat > agent-allow.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "ServiceState", "Effect": "Allow",
"Action": ["ecs:ListClusters", "ecs:DescribeClusters", "ecs:ListServices", "ecs:DescribeServices",
"cloudwatch:DescribeAlarms", "cloudwatch:GetMetricData", "cloudwatch:ListMetrics",
"logs:DescribeLogGroups"],
"Resource": "*" },
{ "Sid": "OneServiceLogs", "Effect": "Allow",
"Action": ["logs:FilterLogEvents", "logs:GetLogEvents"],
"Resource": "arn:aws:logs:eu-central-1:111122223333:log-group:/ecs/checkout:*" }
]
}
EOFBecause
platform-engineer is itself a role, AWS requires sts:SetSourceIdentity on both sides of the chain, and caps the session at one hour:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# once: the agent's role, its allow list, and the human side of the chain
aws iam create-role --role-name agent-view-only --assume-role-policy-document file://agent-trust.json
aws iam put-role-policy --role-name agent-view-only --policy-name diagnose-checkout \
--policy-document file://agent-allow.json
aws iam put-role-policy --role-name platform-engineer --policy-name start-agent-sessions --policy-document \
'{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["sts:AssumeRole","sts:SetSourceIdentity"],"Resource":"arn:aws:iam::111122223333:role/agent-view-only"}]}'
# before every run: a fresh session, recorded before the agent starts
creds=$(aws sts assume-role \
--role-arn arn:aws:iam::111122223333:role/agent-view-only \
--role-session-name "agent-run-$(date -u +%Y%m%dT%H%M%SZ)" \
--source-identity "agent-$USER" --duration-seconds 3600 \
--query Credentials --output json)
aws configure set --profile agent-run aws_access_key_id "$(jq -r .AccessKeyId <<<"$creds")"
aws configure set --profile agent-run aws_secret_access_key "$(jq -r .SecretAccessKey <<<"$creds")"
aws configure set --profile agent-run aws_session_token "$(jq -r .SessionToken <<<"$creds")"
echo "$(date -u +%FT%TZ) start $(jq -r .AccessKeyId <<<"$creds")" >> agent-runs.logStart the agent itself with
AWS_PROFILE=agent-run, not only its MCP server, because a shell finds whatever credentials it can. Better still, run it where that profile is the only one: a container or a separate OS user.Owner: the cloud platform team, with security approving any read added to the role.
Layer 2: send the agent through the managed server, and put a second lock in an SCP (day one)
Connect the agent through the SigV4 proxy, signed with the layer 1 session. The OAuth route skips the proxy and the profile: the agent acts as whoever signed in. Every version of the
-cli package carries the July fix:1
2
3
4
5
6
7
8
9
10
11
# Claude Code: the managed AWS MCP Server through a pinned proxy, signed with the agent's session from layer 1
claude mcp add-json aws-mcp '{
"type": "stdio",
"command": "uvx",
"args": [
"mcp-proxy-for-aws-cli@1.7.0",
"https://aws-mcp.eu-central-1.api.aws/mcp",
"--profile", "agent-run",
"--metadata", "AWS_REGION=eu-central-1"
]
}'There is no
--read-only here, on purpose: the role's allow list does the limiting.People will still run agents as themselves: AWS's getting-started examples pass no profile at all. For them, a service control policy refuses the writes that hurt most, only when the request arrives through an AWS managed MCP server:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# no-writes-via-mcp.json, a service control policy applied by aws organizations: no account changes through an agent
cat > no-writes-via-mcp.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "NoWritesThroughAnAgent",
"Effect": "Deny",
"Action": ["iam:Attach*", "iam:Create*", "iam:Delete*", "iam:Detach*", "iam:Put*", "iam:Update*",
"sts:AssumeRole", "lambda:InvokeFunction", "s3:Delete*", "s3:Put*",
"dynamodb:DeleteItem", "dynamodb:PutItem", "dynamodb:UpdateItem",
"sqs:ReceiveMessage", "sqs:DeleteMessage", "sqs:PurgeQueue"],
"Resource": "*",
"Condition": { "Bool": { "aws:ViaAWSMCPService": "true" } }
}]
}
EOF
aws organizations create-policy --name no-writes-through-an-agent --type SERVICE_CONTROL_POLICY \
--description "Deny account changes made through AWS managed MCP servers" --content file://no-writes-via-mcp.json
aws organizations attach-policy --policy-id p-examplepolicyid111 --target-id ou-examplerootid111-exampleouid111lambda:InvokeFunction and sts:AssumeRole are there because the mark does not travel: a Lambda function the agent invokes acts under its own role. Without AWS Organizations, attach the same JSON to each human role.Owner: the platform team ships the client configuration, and security owns the SCP.
Layer 3: invite the witness before the run, and ask it afterwards (day one, before the first run)
Record the agent role's data-event writes on the multi-Region trail, filtered by
userIdentity.arn. put-event-selectors replaces the whole selector set and runs only in the trail's home Region, so the file carries the selectors the trail already has. Add AWS::DynamoDB::Table the same way for item writes:1
2
3
4
5
6
7
8
9
10
11
12
13
14
# agent-selectors.json, applied by aws cloudtrail put-event-selectors in the trail's home Region
cat > agent-selectors.json <<'EOF'
[
{ "Name": "Management events, as the trail already records them",
"FieldSelectors": [ { "Field": "eventCategory", "Equals": ["Management"] } ] },
{ "Name": "Object writes by agent sessions",
"FieldSelectors": [
{ "Field": "eventCategory", "Equals": ["Data"] },
{ "Field": "resources.type", "Equals": ["AWS::S3::Object"] },
{ "Field": "readOnly", "Equals": ["false"] },
{ "Field": "userIdentity.arn", "StartsWith": ["arn:aws:sts::111122223333:assumed-role/agent-view-only/"] } ] }
]
EOF
aws cloudtrail put-event-selectors --trail-name main-trail --advanced-event-selectors file://agent-selectors.jsonCloudTrail Lake closed to new customers on May 31, 2026, so read the trail with Athena. Event history gives a free first look at management calls, in us-east-1 and every Region the agent used.
The real check reads the trail by the session's ARN, because AWS shows the ARN on forwarded calls and does not document the key. Failed and denied calls carry an
errorcode:1
2
3
4
5
6
7
-- Athena, trail table from AWS's partition projection guide (one per Region): the session's non-read calls
SELECT eventtime, eventsource, eventname, errorcode, useridentity.invokedby
FROM cloudtrail_logs_pp
WHERE useridentity.arn = 'arn:aws:sts::111122223333:assumed-role/agent-view-only/agent-run-20261003T101500Z'
AND coalesce(readonly, 'false') <> 'true'
AND "timestamp" >= '2026/10/03'
ORDER BY eventtime;Then plant one write. In a sandbox account, ask the agent to write a single object through the managed server, and run the check. The write should come back, or its AccessDenied should. If neither does, your witness cannot see that path, and you found out in a sandbox. Log files arrive in about five minutes on average, and AWS adds that "this time is not guaranteed," so check again later. Run every case three times: an agent asked twice can take a different road.
Owner: security engineering, with the platform team running both checks in the pipeline that starts agent sessions.
Three questions for whoever signs off
A Cloud Security Alliance survey published in April 2026, commissioned by Token Security, found that 65% of organizations had an AI agent incident in the previous twelve months, and 61% of those reported data exposure. Nobody needs to read the code to ask the three questions that decide whether that happens quietly.
Which role does each agent run as, and can that role write? Can we see every call the last agent session made, including the denied ones? Which of our MCP servers were installed from a one-click button, and with which arguments?
If any answer is "the agent's own report," the witness is missing.
Tradeoffs
An allow list costs work. Every new kind of task needs its reads added, and an agent that meets AccessDenied stops halfway. An open RFC in AWS's startups repository took the other road in September, ReadOnlyAccess with denies, and it misses the same way the first version of this article did: it denies
lambda:GetFunction* while lambda:ListFunctions returns the same variables. A deny list holds only while it is complete. An allow list fails closed.Data events cost $0.10 per 100,000. Recording every request on a bucket that serves 50 million a day costs about $1,500 a month. Recording one agent role's writes costs cents.
Skip all of it and nothing breaks today. The cost arrives on the day an agent's report says it only looked, and the only record that could disagree was never switched on.
Switch the witness on first.
The closing argument
I would give an agent the managed AWS MCP Server today for diagnosis, under a role that allows only the reads the diagnosis needs, with the organization-wide block on agent writes in place and the witness recording. I would hold on any role that can write until a planted write has come back from the trail. Two guardrails failed this year and IAM held both times, because the guardrails lived inside the agent's tools, and IAM sits on the credentials, outside anything the agent runs. Ask the agent what it did and you get its opinion of itself. Ask CloudTrail and you get a record, but only of the rooms you wired.
Sources
- AWS, Security Bulletin 2026-063 and mcp-proxy-for-aws pull request 350 , July 2026
- awslabs/mcp, migration guide and EKS MCP Server README
- AWS, AWS MCP Server and IAM , its CloudTrail logging and the April Security Blog post
- AWS, ViewOnlyAccess, version 46 and the Lambda API reference for ListVersionsByFunction
- AWS, job function policies , Lambda variable encryption , SQS visibility timeout , CloudTrail data events
- Cloud Security Alliance survey , April 2026, and awslabs/startups RFC 338
- DevOps.Pink, I Told My AI Agent It Was Read-Only , September 2026
Discussion
If you run an AI agent in an AWS account, tell me in the comments which credentials it holds, and whether anything other than the agent checks what it did. That is where I answer first.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article