
Making AWS Operations by AI Safe: sudo-like IAM Privilege Escalation Built with Claude Code × Bedrock
This page has been translated by machine translation. View original
There are more and more situations where AI agents operate AWS directly. In the previous article, I used the custom agent feature of Kiro CLI to introduce a sudo-like mechanism for escalating IAM privileges.
This time, I tried the same three-layer IAM separation architecture with "Claude Code + Amazon Bedrock".
Prerequisite: Bedrock Backend Configuration
In this article, I adopted a configuration that uses "Amazon Bedrock" as the LLM backend for Claude Code. When going through Bedrock, Claude Code internally calls bedrock:InvokeModel, so the role specified by AWS_PROFILE needs Bedrock permissions. For that reason, I granted Bedrock permissions to the ReadOnly and Elevated roles (not to the EC2 role. The reason is explained in the architecture section).
Note that if you use a Claude Max subscription or an Anthropic API key, LLM calls do not go through Bedrock. In that case, granting Bedrock permissions is unnecessary. If you use the template in this article as is, Bedrock API charges may be incurred unintentionally, so please choose a configuration that fits your own usage.
Architecture
IAM roles are separated into three layers, and privileges are escalated step by step through an AssumeRole chain.
| Role | Permissions | Purpose |
|---|---|---|
EC2 role (claudecode-ec2-*) |
SSM + AssumeRole(ReadOnly) | Foundation of the instance |
ReadOnly (claudecode-readonly-*) |
ReadOnlyAccess + Bedrock + AssumeRole(Elevated) | Read operations |
Elevated (claudecode-elevated-*) |
PowerUserAccess + Bedrock | Change operations |
The EC2 role has no Bedrock permissions. Launching claude without specifying a profile results in an error, so this follows the principle of least privilege and also works as a fail-safe against operational mistakes.
This design also has a secondary security benefit. Claude Code may use Docker when executing code, so ec2-user is granted Docker execution permission (the docker group). Membership in the Docker group is a powerful privilege equivalent to root on the host OS, but in this configuration the EC2 IAM role itself has no access permissions to AWS resources at all (other than AssumeRole and SSM). Even if AI-generated code were to escape from a Docker container to the host OS, damage to the AWS environment would be blocked at the IAM level.
The difference from the previous Kiro CLI version is that Bedrock permissions (bedrock:InvokeModel, etc.) are granted to the ReadOnly and Elevated roles. Because Claude Code itself runs using Bedrock, I made it so that Claude Code can be used with whichever profile it is launched.
How It Works
Switching Privileges with AWS_PROFILE
Claude Code determines the AWS profile to use from the AWS_PROFILE environment variable. By associating a different IAM role with each profile, you can choose the privileges at launch.
# Launch with ReadOnly
AWS_PROFILE=claudecode-readonly claude
# Launch with Elevated
AWS_PROFILE=claudecode-elevated claude
In Kiro CLI you could switch profiles within a session with /agent swap, but with Claude Code you need to restart the session. This difference is discussed later.
AWS CLI config
Define the profiles in ~/.aws/config. They are placed automatically by the UserData of the CloudFormation template, so manual creation is unnecessary.
[profile claudecode-readonly]
role_arn = arn:aws:iam::<ACCOUNT_ID>:role/claudecode-readonly-<STACK_NAME>
credential_source = Ec2InstanceMetadata
role_session_name = claudecode-readonly-session
[profile claudecode-elevated]
role_arn = arn:aws:iam::<ACCOUNT_ID>:role/claudecode-elevated-<STACK_NAME>
source_profile = claudecode-readonly
role_session_name = claudecode-elevated-session
With credential_source = Ec2InstanceMetadata, AssumeRole starts from the credentials of the EC2 instance profile. With source_profile = claudecode-readonly, elevated chains through readonly. By setting role_session_name, CloudTrail logs record claudecode-readonly-session or claudecode-elevated-session, so you can tell at a glance which profile was used for an operation.
Sharing Bedrock Permissions
Bedrock permissions are defined as a shared managed policy and attached to both the ReadOnly and Elevated roles.
BedrockPolicy:
Type: AWS::IAM::ManagedPolicy
Properties:
ManagedPolicyName: !Sub claudecode-bedrock-${AWS::StackName}
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- bedrock:InvokeModel
- bedrock:InvokeModelWithResponseStream
- bedrock:ListFoundationModels
- bedrock:ListInferenceProfiles
Resource: '*'
Avoiding Circular References in IAM Roles
As in the previous Kiro CLI version, circular references are avoided by using Principal: root + Condition: ArnEquals in the trust policy.
ReadOnlyRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub claudecode-readonly-${AWS::StackName}
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
AWS: !Sub arn:aws:iam::${AWS::AccountId}:root
Action: sts:AssumeRole
Condition:
ArnEquals:
aws:PrincipalArn: !Sub arn:aws:iam::${AWS::AccountId}:role/claudecode-ec2-${AWS::StackName}
UserData
UserData does the following four things.
- Install Claude Code (
dnf install nodejs→npm install -g @anthropic-ai/claude-code) - Persist Bedrock environment variables (place them in
/etc/profile.d/so they are reliably loaded by login shells) - Place
~/.aws/config(3 profile definitions) - Set up Docker (because Claude Code may use it as a sandbox when executing code)
# Claude Code
dnf install -y unzip git jq nodejs docker
npm install -g @anthropic-ai/claude-code
# Bedrock environment variables (reliably applied to login shells via /etc/profile.d/)
printf '#!/bin/bash\nexport CLAUDE_CODE_USE_BEDROCK=1\nexport ANTHROPIC_MODEL=us.anthropic.claude-sonnet-4-6\nexport AWS_REGION=${AWS::Region}\n' > /etc/profile.d/claude-bedrock.sh
chmod 644 /etc/profile.d/claude-bedrock.sh
In the Kiro CLI version, placing agent JSON files was required, but with Claude Code it is not. The AWS_PROFILE environment variable alone determines the privileges.
The full template is included in "Reference Information" at the end of the article.
Deployment
aws cloudformation create-stack \
--stack-name claudecode-profile-test \
--template-body file://cfn-claudecode.yaml \
--capabilities CAPABILITY_NAMED_IAM \
--region us-west-2
After the stack creation completes, connect via SSM.
aws ssm start-session --target <InstanceId> --region us-west-2
sudo su - ec2-user
Wait for UserData execution to finish. Even when SSM connection is possible, processing may still be in progress, so be sure to confirm completion with the following command.
cloud-init status --wait
$ cloud-init status --wait
..............................................................
status: done
When status: done is displayed, exit once and log in again with sudo su - ec2-user. The aliases added to .bashrc by UserData (claude-ro / claude-el) and the environment variables in /etc/profile.d/ will be reflected in the shell.
exit
sudo su - ec2-user
<details>
<summary>Troubleshooting if cloud-init fails</summary>
# Confirm normal termination ("Cloud-init ... finished" and the elapsed time should appear on the last line)
sudo tail -1 /var/log/cloud-init-output.log
Cloud-init v. 22.2.2 finished at ... Up 130.36 seconds
# Confirm there are no errors
sudo grep -i error /var/log/cloud-init-output.log
</details>
Verification: ReadOnly Profile
I launched Claude Code with the ReadOnly profile.
AWS_PROFILE=claudecode-readonly claude
Read Operation (Success)
I asked it to get a list of S3 buckets.
❯ aws s3 ls を実行して
● Bash(aws s3 ls)
⎿ 2024-11-12 07:00:43 athena-123456789012
2024-11-12 07:00:43 athena-123456789012-apne1
2024-11-09 03:27:04 athena-temp-ap-northeast-1-000000000000
… +35 lines
The read operation worked correctly with the ReadOnly profile.
Change Operation (Denied)
I asked it to create an S3 bucket.
❯ aws s3 mb s3://claudecode-test-0223-delete-me を実行して
● Bash(aws s3 mb s3://claudecode-test-0223-delete-me)
⎿ Error: Exit code 1
make_bucket failed: s3://claudecode-test-0223-delete-me An error occurred
(AccessDenied) when calling the CreateBucket operation: User:
arn:aws:sts::123456789012:assumed-role/claudecode-readonly-claudecode-profile-test/claudecode-readonly-session
is not authorized to perform: s3:CreateBucket on resource:
"arn:aws:s3:::claudecode-test-0223-delete-me" because no identity-based
policy allows the s3:CreateBucket action
With the ReadOnly profile, the change operation was denied by IAM. The session name claudecode-readonly-session appears in the error message, confirming that the role_session_name setting is working.
Verification: Elevated Profile
I exited Claude Code and restarted it with the Elevated profile.
AWS_PROFILE=claudecode-elevated claude
Change Operation (Success)
I asked it to create the same S3 bucket.
❯ aws s3 mb s3://claudecode-test-0223-delete-me を実行して
● Bash(aws s3 mb s3://claudecode-test-0223-delete-me)
⎿ make_bucket: claudecode-test-0223-delete-me
With the Elevated profile (PowerUserAccess), the change operation succeeded. As cleanup, I deleted the bucket.
❯ aws s3 rb s3://claudecode-test-0223-delete-me を実行して
● Bash(aws s3 rb s3://claudecode-test-0223-delete-me)
⎿ remove_bucket: claudecode-test-0223-delete-me
Verification: Launching with Aliases
I launched Claude Code using the aliases set up by UserData and confirmed that responses are returned via Bedrock.
$ claude-ro -p "say hello"
Hello! How can I help you today?
$ claude-el -p "say hi"
Hi! How can I help you today?
With both profiles, responses were returned correctly via Bedrock (Sonnet 4.6). The aliases save you the trouble of typing AWS_PROFILE=claudecode-readonly claude every time.
Summary of Verification Results
| Operation | ReadOnly | Elevated |
|---|---|---|
aws s3 ls (read) |
✅ Success | ✅ Success |
aws s3 mb (change) |
❌ AccessDenied | ✅ Success |
aws s3 rb (delete) |
— | ✅ Success |
claude-ro / claude-el launch |
✅ Bedrock response OK | ✅ Bedrock response OK |
We confirmed that Claude Code's AWS operation privileges can be controlled at the IAM level simply by switching AWS_PROFILE.
Comparison with Kiro CLI
Here is a comparison with Kiro CLI, introduced in the previous article.
| Aspect | Kiro CLI | Claude Code |
|---|---|---|
| Privilege switching | /agent swap elevated |
AWS_PROFILE=... claude |
| Session continuity | Maintained | Restart required |
| Context handover | Automatic (same session) | --resume or via files |
| Control layer | Prompt + profile_name |
Environment variable (OS level) |
| IAM structure | Three layers (common) | Three layers + Bedrock permissions |
| Agent definition | JSON file required | Not required |
Kiro CLI can switch profiles within a session with /agent swap, so context is not interrupted. On the other hand, Claude Code determines the profile by an environment variable, so it has enforcement power that does not depend on the prompt.
Tips for Context Handover
Switching profiles in Claude Code requires restarting the session. There are several ways to make up for the break in context.
Using "claude --resume" lets you restore the conversation history of the previous session. Even if the profile changes, you can carry over what was investigated in the previous session.
If you place "CLAUDE.md" in the project root, Claude Code automatically loads it every time. If you write down working policies and background information, you can maintain a shared context even when the session ends.
In actual operation, the workflow looks like the following.
# 1. Investigate and plan with ReadOnly
claude-ro
# → Have it save the investigation results to plan.md
# → /exit
# 2. Execute with Elevated
claude-el --resume
# → Read plan.md and execute
Conclusion
We confirmed that privilege control through three-layer IAM separation works correctly with Claude Code + Bedrock as well. The environment can be built in one go with the CloudFormation template, and you can switch between ReadOnly / Elevated simply by switching AWS_PROFILE.
The three-layer IAM role structure (EC2 → ReadOnly → Elevated) is a versatile pattern that can be commonly used to reduce the risk of AI operating AWS. Together with the previous Kiro CLI version, please consider an operation that suits your own team and the tools you use.
Reference Information: CloudFormation Template
Here is the CloudFormation template used for verification. You can deploy it with aws cloudformation create-stack and start verification right after connecting via SSM. Cleanup is just aws cloudformation delete-stack.
cfn-claudecode.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: Claude Code + Bedrock with IAM profile switching (ReadOnly / Elevated)
Parameters:
Subnet:
Type: AWS::EC2::Subnet::Id
SecurityGroup:
Type: AWS::EC2::SecurityGroup::Id
InstanceType:
Type: String
Default: t4g.small
VolumeSize:
Type: Number
Default: 10
AmiId:
Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
Default: /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-arm64
Resources:
BedrockPolicy:
Type: AWS::IAM::ManagedPolicy
Properties:
ManagedPolicyName: !Sub claudecode-bedrock-${AWS::StackName}
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- bedrock:InvokeModel
- bedrock:InvokeModelWithResponseStream
- bedrock:ListFoundationModels
- bedrock:ListInferenceProfiles
Resource: '*'
ReadOnlyRole:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub claudecode-readonly-${AWS::StackName}
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
AWS: !Sub arn:aws:iam::${AWS::AccountId}:root
Action: sts:AssumeRole
Condition:
ArnEquals:
aws:PrincipalArn: !Sub arn:aws:iam::${AWS::AccountId}:role/claudecode-ec2-${AWS::StackName}
ManagedPolicyArns:
- arn:aws:iam::aws:policy/ReadOnlyAccess
- !Ref BedrockPolicy
Policies:
- PolicyName: AllowAssumeElevated
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: sts:AssumeRole
Resource: !Sub arn:aws:iam::${AWS::AccountId}:role/claudecode-elevated-${AWS::StackName}
ElevatedRole:
Type: AWS::IAM::Role
DependsOn: ReadOnlyRole
Properties:
RoleName: !Sub claudecode-elevated-${AWS::StackName}
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
AWS: !Sub arn:aws:iam::${AWS::AccountId}:root
Action: sts:AssumeRole
Condition:
ArnEquals:
aws:PrincipalArn: !Sub arn:aws:iam::${AWS::AccountId}:role/claudecode-readonly-${AWS::StackName}
ManagedPolicyArns:
- arn:aws:iam::aws:policy/PowerUserAccess
- !Ref BedrockPolicy
Ec2Role:
Type: AWS::IAM::Role
Properties:
RoleName: !Sub claudecode-ec2-${AWS::StackName}
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: ec2.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
Policies:
- PolicyName: AllowAssumeReadOnly
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action: sts:AssumeRole
Resource: !Sub arn:aws:iam::${AWS::AccountId}:role/claudecode-readonly-${AWS::StackName}
InstanceProfile:
Type: AWS::IAM::InstanceProfile
Properties:
InstanceProfileName: !Sub claudecode-ec2-${AWS::StackName}
Roles:
- !Ref Ec2Role
Ec2Instance:
Type: AWS::EC2::Instance
DependsOn: InstanceProfile
Properties:
InstanceType: !Ref InstanceType
ImageId: !Ref AmiId
SubnetId: !Ref Subnet
SecurityGroupIds:
- !Ref SecurityGroup
IamInstanceProfile: !Ref InstanceProfile
MetadataOptions:
HttpTokens: required
HttpEndpoint: enabled
BlockDeviceMappings:
- DeviceName: /dev/xvda
Ebs:
VolumeSize: !Ref VolumeSize
VolumeType: gp3
Tags:
- Key: Name
Value: !Sub claudecode-${AWS::StackName}
UserData:
Fn::Base64: !Sub |
#!/bin/bash
set -eux
dnf install -y unzip git jq nodejs docker
# Claude Code
npm install -g @anthropic-ai/claude-code
# Docker
usermod -aG docker ec2-user
systemctl enable docker
systemctl start docker
# swap 512MB
dd if=/dev/zero of=/swapfile bs=1M count=512
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile swap swap defaults 0 0' >> /etc/fstab
# Bedrock 環境変数
printf '#!/bin/bash\nexport CLAUDE_CODE_USE_BEDROCK=1\nexport ANTHROPIC_MODEL=us.anthropic.claude-sonnet-4-6\nexport AWS_REGION=${AWS::Region}\n' > /etc/profile.d/claude-bedrock.sh
chmod 644 /etc/profile.d/claude-bedrock.sh
# AWS CLI config
mkdir -p /home/ec2-user/.aws
printf '[default]\nregion = ${AWS::Region}\n\n[profile claudecode-readonly]\nrole_arn = ${ReadOnlyRole.Arn}\ncredential_source = Ec2InstanceMetadata\nrole_session_name = claudecode-readonly-session\nregion = ${AWS::Region}\n\n[profile claudecode-elevated]\nrole_arn = ${ElevatedRole.Arn}\nsource_profile = claudecode-readonly\nrole_session_name = claudecode-elevated-session\nregion = ${AWS::Region}\n' > /home/ec2-user/.aws/config
# エイリアス
printf '\nalias claude-ro="AWS_PROFILE=claudecode-readonly claude"\nalias claude-el="AWS_PROFILE=claudecode-elevated claude"\n' >> /home/ec2-user/.bashrc
chown -R ec2-user:ec2-user /home/ec2-user/.aws
Outputs:
InstanceId:
Value: !Ref Ec2Instance
ReadOnlyRoleArn:
Value: !GetAtt ReadOnlyRole.Arn
ElevatedRoleArn:
Value: !GetAtt ElevatedRole.Arn
SsmConnectCommand:
Value: !Sub aws ssm start-session --target ${Ec2Instance} --region ${AWS::Region}
Deployment and Cleanup
Deploy command:
aws cloudformation create-stack \
--stack-name claudecode-profile-test \
--template-body file://cfn-claudecode.yaml \
--capabilities CAPABILITY_NAMED_IAM \
--parameters \
ParameterKey=Subnet,ParameterValue=<YOUR_SUBNET_ID> \
ParameterKey=SecurityGroup,ParameterValue=<YOUR_SG_ID> \
--region us-west-2
Cleanup:
aws cloudformation delete-stack --stack-name claudecode-profile-test --region us-west-2
