How to Scope an AI Agent to Least Privilege in the Cloud
Every agent demo ends the same way: something throws AccessDenied, someone attaches AdministratorAccess "just to get it working", and nobody ever takes it back off. This guide builds a least privilege AI agent instead: a role that started with nothing, a policy built from the calls the agent really made, and a deny guardrail that survives the next "just add admin" pull request.
The examples use AWS and IAM Access Analyzer policy generation. The loop is cloud-neutral; the commands are not.
1. Create the agent identity with zero permissions
A new IAM role can do nothing until you attach a policy. The trust policy only says who may assume it, so swap the principal for your own compute (ECS tasks here).
cat > trust.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "ecs-tasks.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
EOF
aws iam create-role \
--role-name support-agent \
--assume-role-policy-document file://trust.json
2. Run the agent and read the denials
Run the agent's real tasks in a dev account, including the awkward ones. It will fail at the first call, which is useful: CloudTrail records every denied call. This lists them (events can lag by several minutes).
aws cloudtrail lookup-events \
--start-time 2026-10-11T09:00:00Z \
--query 'Events[].CloudTrailEvent' --output json \
| jq -r '.[] | fromjson
| select(.errorCode != null
and .userIdentity.sessionContext.sessionIssuer.arn == "arn:aws:iam::<your-account-id>:role/support-agent")
| [.eventSource, .eventName, .errorCode] | @tsv'
lookup-events only returns management events, so object-level S3 calls will not show up here.
3. Open a short learning window in dev
An agent stops at its first denial, so it never reveals its second call. In the dev account only, grant the services you saw in step 2, rerun the same tasks, and let the log fill.
aws iam put-role-policy \
--role-name support-agent \
--policy-name learning-window \
--policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["secretsmanager:*","s3:*"],"Resource":"*"}]}'
This is the only broad grant in the process: temporary, dev-only, and deleted in step 7.
4. Generate the policy from CloudTrail activity
You need a CloudTrail trail logging to S3, plus a service role that lets Access Analyzer read it. The easiest first run is the IAM console: open the role, choose Generate policy, and let it create the service role.
To script it, describe the trail for the start-policy-generation CLI command. Set startTime to just before your learning run.
{
"accessRole": "arn:aws:iam::<your-account-id>:role/service-role/<your-access-analyzer-role>",
"startTime": "2026-10-11T09:00:00Z",
"trails": [
{
"cloudTrailArn": "arn:aws:cloudtrail:us-east-1:<your-account-id>:trail/<your-trail>",
"allRegions": true
}
]
}
Save that as trail-details.json, then start the job and fetch the result with get-generated-policy:
JOB_ID=$(aws accessanalyzer start-policy-generation \
--policy-generation-details '{"principalArn":"arn:aws:iam::<your-account-id>:role/support-agent"}' \
--cloud-trail-details file://trail-details.json \
--query jobId --output text)
# repeat until this prints SUCCEEDED
aws accessanalyzer get-generated-policy --job-id "$JOB_ID" \
--query jobDetails.status --output text
aws accessanalyzer get-generated-policy --job-id "$JOB_ID" \
--include-resource-placeholders \
--query 'generatedPolicyResult.generatedPolicies[0].policy' \
--output text > generated-policy.json
The placeholder flag swaps * for ARN stubs like arn:aws:s3:::${BucketName} where an action supports resource-level permissions.
5. Tighten it into a least privilege AI agent policy
Open generated-policy.json and make three edits. Replace placeholders and wildcards with real ARNs. Add the actions CloudTrail cannot see: policy generation does not identify action-level activity for data events such as S3 object calls, and iam:PassRole is not tracked at all. Then add a condition.
The result is agent-policy.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadCrmSecret",
"Effect": "Allow",
"Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:us-east-1:<your-account-id>:secret:support-agent/crm-*",
"Condition": {
"StringEquals": { "aws:RequestedRegion": "us-east-1" }
}
},
{
"Sid": "ReadKnowledgeBase",
"Effect": "Allow",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::<your-bucket>/kb/*"
},
{
"Sid": "WriteDrafts",
"Effect": "Allow",
"Action": "s3:PutObject",
"Resource": "arn:aws:s3:::<your-bucket>/drafts/*"
}
]
}
The secret ARN ends in -* because Secrets Manager appends a random suffix to every secret ARN.
6. Add a deny guardrail that outlives the next shortcut
An explicit deny beats every allow, so these refusals hold even when someone later attaches a broad policy to the role "temporarily". The second statement pins all S3 access to the agent's own bucket.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "NoIdentityOrAuditChanges",
"Effect": "Deny",
"Action": [
"iam:*",
"organizations:*",
"cloudtrail:StopLogging",
"cloudtrail:DeleteTrail",
"cloudtrail:UpdateTrail"
],
"Resource": "*"
},
{
"Sid": "S3OnlyInAgentBucket",
"Effect": "Deny",
"Action": "s3:*",
"NotResource": [
"arn:aws:s3:::<your-bucket>",
"arn:aws:s3:::<your-bucket>/*"
]
}
]
}
Save it as guardrail.json. For a ceiling that editing the role's own policies cannot raise, also set a permissions boundary with aws iam put-role-permissions-boundary --role-name support-agent --permissions-boundary <policy-arn>.
7. Swap the policies and re-test
Remove the learning grant, attach the real policy and the guardrail, then ask IAM what it would decide before you run anything.
aws iam delete-role-policy --role-name support-agent --policy-name learning-window
aws iam put-role-policy --role-name support-agent \
--policy-name agent-permissions --policy-document file://agent-policy.json
aws iam put-role-policy --role-name support-agent \
--policy-name agent-guardrail --policy-document file://guardrail.json
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::<your-account-id>:role/support-agent \
--action-names s3:GetObject s3:DeleteObject \
--resource-arns arn:aws:s3:::<your-bucket>/kb/faq.md arn:aws:s3:::<other-bucket>/x \
--query 'EvaluationResults[].[EvalActionName,EvalResourceName,EvalDecision]' \
--output table
Only s3:GetObject on the knowledge-base object should say allowed. Then rerun the full task suite and the step 2 query. Any new denial is a missing permission to add on purpose, not a reason to go back to admin.
Gotchas
- Denied calls count. Policy generation reviews all CloudTrail events, including denied ones, so a probe from step 2 can land in the template. Read every action before you keep it.
- Coverage equals your test run. If the refund tool never ran during the window, its permission is missing. Replay your whole eval set, and regenerate whenever you add a tool.
- Trail requirements. Trails created by AWS Control Tower are not supported by policy generation, and the caller needs Access Analyzer permissions plus
iam:PassRoleon the service role.
A prompt can ask an agent to behave, but only its IAM policy can force it to, so the policy is the real limit on what a confused or hijacked agent can do in production.