The iam:PassRole Mistake Your Coding Agent Keeps Making
Give a coding agent this prompt:
I have an orchestrator Lambda that needs to launch ECS worker tasks for my agents. Write the IAM role.
It’s easy to get an answer like this one, which is the “before” fixture in the free skill linked below:
resource "aws_iam_role_policy" "orchestrator" {
role = aws_iam_role.orchestrator.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{ Effect = "Allow", Action = "ecs:*", Resource = "*" },
{ Effect = "Allow", Action = "iam:PassRole", Resource = "*" }
]
})
}It deploys. The orchestrator launches its tasks and the demo works. It also permits passing any same-account role that the target service can assume, subject to the rest of the effective permissions. That can expose much more privilege than this task requires.
Why PassRole on * is the dangerous line
ecs:* on * is bad: the orchestrator can stop any service, register new task definitions and run them in any cluster. But the second statement is the one that turns a bug into a breach.
iam:PassRole is the permission to hand an existing role to an AWS service, so the service can act as that role. When an ECS task starts, ECS assumes whatever task role the task definition names. If a privileged role trusts ecs-tasks.amazonaws.com, the caller can register and run a compatible task definition, and no other control denies the request, this broad policy can let code run with that role. PassRole alone does not bypass a role’s trust policy or grant every permission in the account. AWS documents this as the reason PassRole exists as a separate permission: it stops users from granting a service more access than they have themselves.
With a human operator, that is an escalation path someone has to find and exploit deliberately. With an agent, the orchestrator is already acting on text it did not write: tool outputs, retrieved documents, user messages. A prompt injection or a plain reasoning error only needs to produce the right sequence of API calls. Effective permissions, service trust, resource configuration and execution controls determine whether the sequence works.
The scoped version
Here is the same orchestrator, scoped to its actual job:
resource "aws_iam_role_policy" "orchestrator" {
role = aws_iam_role.orchestrator.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
# Run only the worker task family, only in the agents cluster.
Sid = "RunWorkerTasks"
Effect = "Allow"
Action = "ecs:RunTask"
Resource = "arn:aws:ecs:${var.region}:${var.account_id}:task-definition/agent-worker:*"
Condition = {
ArnEquals = { "ecs:cluster" = aws_ecs_cluster.agents.arn }
}
},
{
# Pass only the worker's roles, and only to ECS tasks.
Sid = "PassWorkerRolesToEcsOnly"
Effect = "Allow"
Action = "iam:PassRole"
Resource = [
aws_iam_role.worker_task.arn,
aws_iam_role.worker_execution.arn,
]
Condition = {
StringEquals = { "iam:PassedToService" = "ecs-tasks.amazonaws.com" }
}
},
{
# An explicit Deny wins over any Allow added by a later edit.
Sid = "DenyPrivilegeEscalation"
Effect = "Deny"
Action = [
"iam:CreateRole",
"iam:AttachRolePolicy",
"iam:PutRolePolicy",
"iam:CreatePolicyVersion",
"iam:UpdateAssumeRolePolicy",
]
Resource = "*"
}
]
})
}
# Blast radius: if this orchestrator is fully compromised, it can start
# agent-worker tasks in one cluster, running as the worker roles. Under this policy alone it cannot
# pass other roles or use other ECS actions. Review all other policies, role
# trust, task-definition authors and worker permissions before accepting that bound.Four details carry most of the weight:
- PassRole names specific roles. An ECS task can have two roles: the task role your code runs as, and the execution role ECS uses to pull images and write logs. If the task definition sets both, the caller must be allowed to pass both. Coding agents often remember only one and you get a runtime
AccessDeniedonRunTask. The response is to add the missing ARN, not to widen the resource back to*. iam:PassedToServicepins where the role can go. It constrains the service to which the role can be passed. Another service would also need compatible role trust and corresponding caller permissions. The condition key costs one line.- The task definition is scoped by family, with a wildcard revision. A full ARN including
:7breaks the next time the worker is deployed.agent-worker:*keeps the scope to one family and survives new revisions. - The escalation Deny is insurance against the next edit. Six months from now, someone (quite possibly an agent) will add an
Allowto fix a failing deploy. The explicitDenymeans that fix cannot silently add a role-creation path.
What not to over-correct
A rule like “never write Resource: "*"” sounds safe and is wrong. Some actions don’t support resource-level permissions at all: sts:GetCallerIdentity, many List* and Describe* calls, and ecr:GetAuthorizationToken, for example. For those, * is the only valid value. Whether an action supports resource scoping is listed per action in the Service Authorization Reference. The useful rule is narrower: scope resources wherever the action supports it; document any required wildcard and constrain it using supported condition keys and other controls.
The same goes for Deny statements. Resource: "*" on a Deny is how you close a door everywhere, and it’s correct in the example above.
A review checklist for agent IAM
Use this in code review whenever a role belongs to something that can invoke, provision or spawn other workloads:
- No
"Action": "*", and no service-wideservice:*on an agent role. - Scope mutating, compute-launching and role-passing actions to supported resources. Document API operations that require
"Resource": "*"and apply supported conditions. - Every
iam:PassRolenames specific role ARNs and has aniam:PassedToServicecondition. - A child workload can never assume its parent’s role (check the parent role’s trust policy).
- An explicit Deny exists for role and policy creation, unless creating roles is a documented requirement. If it is, the created roles must carry a permissions boundary, enforced with the
iam:PermissionsBoundarycondition onCreateRole. - The file has a one- or two-sentence blast-radius note, and the worst case it describes is acceptable.
That last item feels like ceremony until you try it. Writing “if this is compromised, it can…” forces the reviewer to read the policy as an attacker would, and the comment stays in the file for whoever edits it next.
Checking what the role actually uses
A tight policy written from the design can still be wrong about what the code calls. Two AWS features help close the gap:
- IAM Access Analyzer policy generation builds a policy from the role’s CloudTrail activity. Run the agent through its real workload in a non-production account, generate, and compare against what you wrote.
- Access Analyzer’s unused access findings flag permissions that haven’t been used, which is how drift shows up months later.
Neither replaces review. Generated policies only reflect the paths your test run exercised, and they cannot tell you whether a permission should exist.
Where this stops
IAM limits what a compromised agent can do. It doesn’t stop the compromise, it won’t catch an agent misusing a permission it legitimately holds (deleting the wrong item from a table it owns), and it’s only one layer. Prompt-injection boundaries, human approval for irreversible actions, and audit logs are separate controls. The point of scoping the role is that when those other layers fail, the orchestration path is limited to the reviewed cluster and task family. The worker roles, task definitions and reachable systems still determine the resulting damage.