Making AWS Operations by AI Safe: sudo-like IAM Privilege Escalation Built with Claude Code × Bedrock

Making AWS Operations by AI Safe: sudo-like IAM Privilege Escalation Built with Claude Code × Bedrock

Combining Claude Code with Amazon Bedrock to separate AWS permissions. Using Sonnet 4.6, switch AWS_PROFILE for sudo-like ReadOnly/Elevated operation, with least-privilege EC2 roles guarding against Docker risks at the IAM level.
2026.02.24

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.

https://dev.classmethod.jp/articles/kiro-cli-custom-agent-sudo/

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.

  1. Install Claude Code (dnf install nodejs → npm install -g @anthropic-ai/claude-code)
  2. Persist Bedrock environment variables (place them in /etc/profile.d/ so they are reliably loaded by login shells)
  3. Place ~/.aws/config (3 profile definitions)
  4. 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

References


Claudeならクラスメソッドにお任せください

クラスメソッドは、Anthropic社とリセラー契約を締結しています。各種製品ガイドから、業種別の活用法、フェーズごとのお悩み解決などサービス支援ページにまとめております。まずはご覧いただき、お気軽にご相談ください。

サービス詳細を見る

Share this article

AWSのお困り事はクラスメソッドへ