AWS IAM Policies skill for Codex
Least privilege you can prove, not promise. Codex maps every API call a workload makes to its exact action, ARN and condition keys, then writes one scoped statement per resource type. It locks GitHub OIDC trust to a repo and environment, scopes iam:PassRole, reasons through SCPs and boundaries when debugging AccessDenied, and checks the result with a bundled offline lint script plus Access Analyzer validation.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- aws-iam-policies Installs to ~/.agents/skills/aws-iam-policies/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install aws-iam-policies The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project aws-iam-policies Installs to .agents/skills/aws-iam-policies/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/aws-iam-policies.zipunzip -o aws-iam-policies.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) aws-iam-policies PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $aws-iam-policies, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes and audits least-privilege AWS IAM policies, roles and trust policies - specific actions and ARNs, condition keys like aws:PrincipalOrgID and aws:SourceArn, iam:PassRole scoping, GitHub Actions OIDC trust, permission boundaries - and checks them with IAM Access Analyzer, the policy simulator and an offline lint script. Use when creating a role for a service or CI pipeline, debugging AccessDenied, tightening a wildcard policy, reviewing a bucket or KMS key policy, or generating a policy from CloudTrail activity.
Use it when
- Creating a role for a Lambda, ECS task, CI pipeline or cross-account access
- Debugging an AccessDenied error
- Tightening a policy full of wildcards
- Reviewing bucket, queue or KMS key resource policies
Use something else when
- The task is IAM in Google Cloud: use gcp-iam
- You are hardening CI tokens and workflow permissions broadly: use github-actions-security
How to check the result
The lint script and Access Analyzer validate-policy report no errors or security warnings, and simulated or real calls succeed for required actions and fail for everything else.
The mistake it prevents
Granting iam:PassRole on Resource * lets anyone who can create a Lambda or ECS task run code as any role in the account.
Done well means: every Allow names specific actions on specific ARNs, wildcards survive only where the service does not support resource-level permissions, trust policies say exactly who may assume a role, and the policy has been validated by Access Analyzer and tested against the real calls the workload makes.
Evaluation model you must reason with
A request is allowed only if nothing denies it and something allows it, across every policy type that applies:
- An explicit
Denyanywhere wins. - Organization SCPs and RCPs must allow it (they never grant, only limit).
- A permissions boundary on the principal must allow it.
- A session policy (passed on
AssumeRole) must allow it. - The identity policy or the resource policy allows it. Within one account either is enough; across accounts both sides must allow it.
- KMS keys are special: the key policy must allow access (directly or by delegating to IAM with the account root principal).
When debugging AccessDenied, the error message usually names the policy type that denied
(with an explicit deny in a service control policy, because no identity-based policy allows). Read it before editing anything.
Workflow
- Read
AGENTS.md; find where IAM is defined (CDK, Terraform, CloudFormation, SAM) - List the exact API calls the workload makes (code, SDK calls, CloudTrail)
- Map each call to its action, resource ARN format and condition keys in the Service Authorization Reference
- Write the policy with one statement per resource type and a
Sideach - Run the offline lint script, then Access Analyzer validation
- Simulate or exercise the real calls; confirm denied calls stay denied
- Show the user any change that widens access before applying it
Writing the policy
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadOrderExports",
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::acme-exports-prod/orders/*"
},
{
"Sid": "ListOrderPrefixOnly",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::acme-exports-prod",
"Condition": { "StringLike": { "s3:prefix": ["orders/*"] } }
},
{
"Sid": "DecryptWithExportKey",
"Effect": "Allow",
"Action": "kms:Decrypt",
"Resource": "arn:aws:kms:eu-west-1:111111111111:key/1234abcd-12ab-34cd-56ef-1234567890ab",
"Condition": { "StringEquals": { "kms:ViaService": "s3.eu-west-1.amazonaws.com" } }
}
]
}
Rules:
- Bucket-level actions (
s3:ListBucket) take the bucket ARN; object actions takebucket/*. Mixing them up is the most common S3 AccessDenied. Resource: "*"is correct only for actions without resource-level support (ec2:Describe*,s3:ListAllMyBuckets,cloudwatch:PutMetricData). Put those in their own statement so the wildcard is obviously deliberate.- Prefer
Actionlists overservice:*. Wildcard verbs likedynamodb:Get*are tolerable for reads; never for writes. - Never use
AllowwithNotActionorNotResource; they grant everything else, including actions AWS adds later. - Always
"Version": "2012-10-17"; the old version does not resolve policy variables such as${aws:PrincipalTag/team}.
iam:PassRole
Any principal that can create Lambdas, ECS tasks, EC2 instances or Glue jobs with
iam:PassRole on * can run code as any role in the account. Scope it:
{
"Sid": "PassOnlyTaskRole",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "arn:aws:iam::111111111111:role/orders-task-role",
"Condition": { "StringEquals": { "iam:PassedToService": "ecs-tasks.amazonaws.com" } }
}
Trust policies
GitHub Actions OIDC, pinned to one repo and one environment:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::111111111111:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:acme/orders-service:environment:production"
}
}
}]
}
Without a sub condition any repository on GitHub can assume the role. Use
StringEquals on an exact sub where possible; StringLike with repo:acme/* only when
the user accepts every repo in the org.
Service trust (Lambda, ECS tasks) should add confused-deputy protection where the service
supports it: aws:SourceAccount and aws:SourceArn conditions.
Cross-account trust: name the role ARN in Principal.AWS rather than the whole account
root, and require sts:ExternalId when a third party assumes the role.
Resource policies
For bucket, queue, topic and key policies that must allow a service or an organization:
| Goal | Condition |
|---|---|
| Any principal in our AWS Organization | aws:PrincipalOrgID = o-xxxxxxxxxx |
| A service acting for one specific resource | aws:SourceArn (and aws:SourceAccount) |
| Only via our VPC endpoint | aws:SourceVpce |
| Only over TLS | Deny when aws:SecureTransport is false |
Decision table
| Situation | Do |
|---|---|
| Existing role is over-broad and has run in production for weeks | Generate a policy from CloudTrail with Access Analyzer policy generation, then review |
| Many teams create roles | Permissions boundary required on every role they create |
| Guardrail for every account (“no one disables CloudTrail”) | SCP, not per-role policies |
| Human access | IAM Identity Center permission sets, not IAM users with keys |
| Workload outside AWS (CI, on-prem) | OIDC federation or IAM Roles Anywhere, never long-lived access keys |
| AWS managed policy is “close enough” | Use it only for read-only or job-function policies; write your own for apps |
Offline lint
scripts/iam_policy_lint.py flags the high-risk patterns above (wildcard actions, write
actions on * without conditions, NotAction, unscoped iam:PassRole, escalation
actions, OIDC trust without sub, Principal: "*" without an org or source condition).
It runs offline with the standard library and exits 1 on any HIGH finding:
python3 scripts/iam_policy_lint.py policy.json trust.json
It accepts a bare policy document or one wrapped in PolicyDocument /
AssumeRolePolicyDocument. For CDK, run cdk synth and extract the policy from
cdk.out; for Terraform, use terraform show -json on a plan.
Validate with AWS (needs network and credentials)
aws accessanalyzer validate-policy --policy-type IDENTITY_POLICY \
--policy-document file://policy.json \
--query 'findings[].[findingType,issueCode,findingDetails]' --output table
# Prove a new version grants nothing beyond the old one (useful in CI)
aws accessanalyzer check-no-new-access --policy-type IDENTITY_POLICY \
--existing-policy-document file://old.json --new-policy-document file://policy.json
aws iam simulate-principal-policy \
--policy-source-arn arn:aws:iam::111111111111:role/orders-task-role \
--action-names s3:GetObject s3:PutObject \
--resource-arns arn:aws:s3:::acme-exports-prod/orders/2026.csv \
--query 'EvaluationResults[].[EvalActionName,EvalDecision]' --output table
Use --policy-type RESOURCE_POLICY with --validate-policy-resource-type AWS::S3::Bucket for bucket policies. The simulator does not evaluate SCPs from other
accounts or every condition key; a real call with the role’s credentials is the final test.
Gotchas
- Policy size limits: managed policies 6,144 characters, inline role policies 10,240 total. Hitting them is a sign of copy-paste rather than structure.
- Tag-based (ABAC) conditions only work for services that support
aws:ResourceTagon that action; check the Service Authorization Reference first. - IAM is eventually consistent; a just-created role can fail
AssumeRolefor a few seconds. Retry rather than widening the policy. - Explicit deny statements in a boundary or SCP are invisible from the role itself. When the role’s own policy looks right, check the boundary and the org.
- Deleting roles, detaching policies in production, or applying an SCP needs explicit user confirmation; an SCP mistake can lock out a whole account.
Output
## IAM change: <role or policy>
**Calls required:** s3:GetObject on acme-exports-prod/orders/*, kms:Decrypt on key/...
**Access removed:** <actions/resources no longer granted>
**Access added:** <with justification>
**Checks:** lint OK | Access Analyzer: 0 errors, 0 security warnings | simulation results
**Risks / follow-ups:** <wildcards kept and why, boundary or SCP interactions> What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "AWS IAM Policies"
short_description: "Least privilege you can prove, not promise"
default_prompt: "$aws-iam-policies Write a least-privilege IAM role for this Lambda based on what the code calls" scripts/iam_policy_lint.py
#!/usr/bin/env python3
"""Offline least-privilege lint for AWS IAM policy JSON files.
Usage: python3 iam_policy_lint.py <policy.json> [more.json ...]
Accepts identity policies, resource policies and role trust policies, either as a
bare policy document or wrapped as {"PolicyDocument": {...}} / {"AssumeRolePolicyDocument": {...}}.
Prints one line per finding and exits 1 if any HIGH finding exists, 2 on unreadable input.
This complements, and does not replace, IAM Access Analyzer validate-policy.
"""
import json
import sys
WRITE_VERBS = ("Put", "Delete", "Create", "Update", "Attach", "Detach", "Pass", "Set",
"Modify", "Remove", "Add", "Terminate", "Run", "Invoke", "Send", "Publish",
"Write", "Tag", "Untag", "Replace", "Restore", "Start", "Stop", "Encrypt",
"Decrypt", "Assume")
ESCALATION = {"iam:createpolicyversion", "iam:setdefaultpolicyversion", "iam:attachrolepolicy",
"iam:attachuserpolicy", "iam:putrolepolicy", "iam:putuserpolicy",
"iam:createaccesskey", "iam:updateassumerolepolicy", "iam:createloginprofile",
"iam:updateloginprofile", "iam:addusertogroup", "iam:attachgrouppolicy",
"iam:putgrouppolicy", "lambda:updatefunctioncode"}
def as_list(v):
if v is None:
return []
return v if isinstance(v, list) else [v]
def unwrap(doc):
for key in ("PolicyDocument", "AssumeRolePolicyDocument", "policyDocument"):
if isinstance(doc, dict) and key in doc:
inner = doc[key]
return json.loads(inner) if isinstance(inner, str) else inner
return doc
def is_write(action):
if ":" not in action:
return action == "*"
verb = action.split(":", 1)[1]
return verb == "*" or verb.startswith(WRITE_VERBS)
def cond_keys(stmt):
keys = set()
for _op, body in (stmt.get("Condition") or {}).items():
if isinstance(body, dict):
keys.update(k.lower() for k in body)
return keys
def lint(doc):
out = []
if doc.get("Version") != "2012-10-17":
out.append(("MEDIUM", "-", 'Version is not "2012-10-17"; policy variables will not resolve'))
for i, s in enumerate(as_list(doc.get("Statement"))):
sid = s.get("Sid") or f"#{i}"
if s.get("Effect") != "Allow":
continue
actions = [a.lower() for a in as_list(s.get("Action"))]
resources = as_list(s.get("Resource"))
ck = cond_keys(s)
principal = s.get("Principal")
if "NotAction" in s:
out.append(("HIGH", sid, "Allow with NotAction grants every action not listed, including future ones"))
if "NotResource" in s:
out.append(("HIGH", sid, "Allow with NotResource grants access to every other resource"))
if "*" in actions:
out.append(("HIGH", sid, 'Action "*" grants full administrator access'))
for a in actions:
if a.endswith(":*") and a != "*":
out.append(("HIGH", sid, f'Service wildcard "{a}"; list the specific actions'))
if "*" in resources and not ck and any(is_write(a) for a in as_list(s.get("Action"))):
out.append(("HIGH", sid, 'Write actions on Resource "*" with no Condition'))
esc = sorted(set(actions) & ESCALATION)
if esc:
out.append(("HIGH", sid, "Privilege-escalation capable actions: " + ", ".join(esc)))
if "iam:passrole" in actions or "iam:*" in actions:
if "*" in resources or "iam:passedtoservice" not in ck:
out.append(("HIGH", sid, "iam:PassRole should name role ARNs and use iam:PassedToService"))
if principal is not None:
pvals = [principal] if principal == "*" else [
v for vals in (principal.values() if isinstance(principal, dict) else []) for v in as_list(vals)]
if "*" in pvals and not ck & {"aws:principalorgid", "aws:sourcearn", "aws:sourceaccount",
"aws:sourcevpce", "aws:principalarn", "aws:sourcevpc"}:
out.append(("HIGH", sid, 'Principal "*" without an org, source or VPC endpoint condition'))
fed = as_list(principal.get("Federated")) if isinstance(principal, dict) else []
if any("oidc-provider" in f or "token.actions.githubusercontent.com" in f for f in fed):
if not any(k.endswith(":sub") for k in ck):
out.append(("HIGH", sid, "OIDC trust without a :sub condition lets any repo or workload assume the role"))
aws_p = as_list(principal.get("AWS")) if isinstance(principal, dict) else []
if "sts:assumerole" in actions and any(":root" in p or p.isdigit() for p in aws_p):
if "sts:externalid" not in ck and "aws:principalarn" not in ck:
out.append(("MEDIUM", sid, "Cross-account trust of a whole account; consider sts:ExternalId or aws:PrincipalArn"))
return out
def main(paths):
if not paths:
print(__doc__.strip().splitlines()[2])
return 2
high = False
for p in paths:
try:
with open(p, encoding="utf-8") as fh:
doc = unwrap(json.load(fh))
except (OSError, json.JSONDecodeError) as exc:
print(f"{p}: ERROR cannot read policy: {exc}")
return 2
findings = lint(doc)
if not findings:
print(f"{p}: OK no findings")
for sev, sid, msg in findings:
high |= sev == "HIGH"
print(f"{p}: {sev} [{sid}] {msg}")
return 1 if high else 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.