Safely operating AWS with AI. Creating sudo-like IAM privilege escalation with Claude Code × Bedrock

Safely operating AWS with AI. Creating sudo-like IAM privilege escalation with Claude Code × Bedrock

Combining Claude Code with Amazon Bedrock to implement separation of privileges for AWS operations. Using the latest model Sonnet 4.6, I'll explain a mechanism for safely performing "sudo-like" ReadOnly/Elevated operations by switching AWS_PROFILE. With minimum privilege design for EC2 roles, this approach also prepares for risks via Docker at the IAM level.
2026.02.24

This page has been translated by machine translation. View original

AI agents that directly operate AWS are becoming increasingly common. In the previous article, I introduced a sudo-like IAM permission escalation mechanism using Kiro CLI's custom agent feature.

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

This time, I tried the same IAM three-tier 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 using Bedrock, Claude Code internally calls bedrock:InvokeModel, so the role specified with AWS_PROFILE needs Bedrock permissions. Therefore, I've granted Bedrock permissions to both ReadOnly and Elevated roles (but not to the EC2 role, for reasons explained in the architecture section).

Note that if you use Claude Max subscription or Anthropic API keys, LLM calls don't go through Bedrock. In that case, granting Bedrock permissions is unnecessary. Using this template as is might result in unintended Bedrock API charges, so please choose a configuration that matches your usage pattern.

Architecture

IAM roles are separated into three tiers, with permissions escalated gradually through AssumeRole chains.

Role Permissions Purpose
EC2 Role (claudecode-ec2-*) SSM + AssumeRole(ReadOnly) Instance foundation
ReadOnly (claudecode-readonly-*) ReadOnlyAccess + Bedrock + AssumeRole(Elevated) Reference operations
Elevated (claudecode-elevated-*) PowerUserAccess + Bedrock Modification operations

The EC2 role does not have Bedrock permissions. If you launch claude without specifying a profile, it will result in an error. This adheres to the principle of least privilege and also serves as a fail-safe against operational mistakes.

This design has a secondary security effect. Since Claude Code may use Docker when executing code, ec2-user is granted Docker execution permissions (the docker group). Being a member of the Docker group is equivalent to having root privileges on the host OS, but in this configuration, the EC2 IAM role itself does not have any access permissions to AWS resources (except for AssumeRole and SSM). Even if AI-generated code manages to escape from a Docker container to the host OS, damage to the AWS environment is blocked at the IAM level.

The difference from the previous Kiro CLI version is that Bedrock permissions (bedrock:InvokeModel, etc.) are granted to both ReadOnly and Elevated roles. This ensures that Claude Code works regardless of which profile it's launched with.

Mechanism

Permission Switching with AWS_PROFILE

The AWS profile used by Claude Code is determined by the AWS_PROFILE environment variable. By linking different IAM roles to each profile, you can select permissions at startup.

# Launch with ReadOnly
AWS_PROFILE=claudecode-readonly claude

# Launch with Elevated
AWS_PROFILE=claudecode-elevated claude

While Kiro CLI allowed switching profiles within a session with /agent swap, Claude Code requires restarting the session. This difference is explained later.

AWS CLI config

Profiles are defined in ~/.aws/config. This is automatically deployed by the CloudFormation template's UserData, so manual creation is not necessary.

[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

credential_source = Ec2InstanceMetadata allows AssumeRole using the EC2 instance profile credentials as a starting point. source_profile = claudecode-readonly makes the elevated profile chain through the readonly one. Setting role_session_name causes CloudTrail logs to record sessions as claudecode-readonly-session or claudecode-elevated-session, making it immediately clear which profile was used.

Sharing Bedrock Permissions

Bedrock permissions are defined as a shared managed policy and attached to both 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 IAM Role Circular References

As with the 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 performs the following four tasks:

  1. Installing Claude Code (dnf install nodejsnpm install -g @anthropic-ai/claude-code)
  2. Persisting Bedrock environment variables (placing them in /etc/profile.d/ to ensure they're loaded in login shells)
  3. Deploying ~/.aws/config (defining 3 profiles)
  4. Setting up Docker (as 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 reflected in 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

The Kiro CLI version required deploying agent JSON files, but Claude Code doesn't need them. Permissions are determined solely by the AWS_PROFILE environment variable.

The complete template is provided in the "Reference Information" section 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 stack creation is complete, connect via SSM.

aws ssm start-session --target <InstanceId> --region us-west-2
sudo su - ec2-user

Wait for UserData execution to complete. Even though the SSM connection may be available, processing might still be ongoing, so be sure to check completion with the following command:

cloud-init status --wait
$ cloud-init status --wait
..............................................................
status: done

Once status: done is displayed, first exit and then log back in with sudo su - ec2-user. This will ensure that aliases (claude-ro / claude-el) added to .bashrc by UserData and environment variables in /etc/profile.d/ are reflected in your shell.

exit
sudo su - ec2-user

<details>
<summary>Troubleshooting if cloud-init fails</summary>

# Check for normal termination ("Cloud-init ... finished" and elapsed time should be in the last line)
sudo tail -1 /var/log/cloud-init-output.log
Cloud-init v. 22.2.2 finished at ... Up 130.36 seconds
# Check that there are no errors
sudo grep -i error /var/log/cloud-init-output.log

</details>

Verification: ReadOnly Profile

I started Claude Code with the ReadOnly profile.

AWS_PROFILE=claudecode-readonly claude

Read Operations (Successful)

I requested a list of S3 buckets.

❯ Execute 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

Read operations work normally with the ReadOnly profile.

Modification Operations (Denied)

I requested creating an S3 bucket.

❯ Execute 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

Modification operations are denied by IAM when using the ReadOnly profile. The error message shows the session name claudecode-readonly-session, 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

Modification Operations (Successful)

I requested the same S3 bucket creation.

❯ Execute 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), modification operations succeed. As cleanup, I deleted the bucket.

❯ Execute 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: Starting with Aliases

I started Claude Code using aliases set in UserData and confirmed that responses return 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?

Responses return normally via Bedrock (Sonnet 4.6) with both profiles. The aliases save the trouble of typing AWS_PROFILE=claudecode-readonly claude every time.

Verification Results Summary

Operation ReadOnly Elevated
aws s3 ls (Read) ✅ Success ✅ Success
aws s3 mb (Modify) ❌ AccessDenied ✅ Success
aws s3 rb (Delete) ✅ Success
claude-ro / claude-el launch ✅ Bedrock response OK ✅ Bedrock response OK

I confirmed that AWS operation permissions for Claude Code can be controlled at the IAM level simply by switching AWS_PROFILE.

Comparison with Kiro CLI

Comparing with Kiro CLI introduced in the previous article:

Aspect Kiro CLI Claude Code
Permission switching /agent swap elevated AWS_PROFILE=... claude
Session continuity Maintained Restart required
Context carryover Automatic (same session) Via --resume or file
Control layer Prompt + profile_name Environment variables (OS level)
IAM structure Three tiers (common) Three tiers + Bedrock permissions
Agent definition JSON file required Not required

Kiro CLI can switch profiles within a session using /agent swap, so context isn't lost. Claude Code, on the other hand, determines profiles through environment variables, giving it enforcement power independent of prompts.

Techniques for Context Carryover

Claude Code requires restarting the session to switch profiles. There are several ways to compensate for this context discontinuity.

Using claude --resume restores the conversation history from the previous session. Even if the profile changes, you can still carry over what was investigated in the previous session.

Placing CLAUDE.md in the project root causes Claude Code to automatically load it every time. If you write down your work approach and background information, you can maintain a common context even if the session is interrupted.

In practical operation, the workflow would look like this:

# 1. Investigate and plan with ReadOnly
claude-ro
# → Have it save investigation results to plan.md
# → /exit

# 2. Execute with Elevated
claude-el --resume
# → Read plan.md and execute

Summary

I confirmed that permission control through IAM's three-tier separation works properly with Claude Code + Bedrock as well. The environment can be built in one go with a CloudFormation template, and ReadOnly/Elevated can be switched simply by changing AWS_PROFILE.

The three-tier IAM role structure (EC2 → ReadOnly → Elevated) is a general pattern that can be used to mitigate risks of AI operating AWS. Together with the Kiro CLI version from the previous article, consider operations that suit your team and tools.

Reference Information: CloudFormation Template

Here is the CloudFormation template used for verification. You can deploy it with aws cloudformation create-stack and start verification immediately after connecting with SSM. Cleanup is simply 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 environment variables
          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

          # Aliases
          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, Cleanup

Deployment 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

Share this article

FacebookHatena blogX