Naming note: Cursor now calls these Cloud Agents; its documentation says they were formerly called Background Agents. They run in isolated cloud VMs, work on a separate branch and push changes for handoff, which in most teams means a pull request. Everything below applies to any agent that opens PRs, whichever tool wrote the diff.
An agent’s AWS change usually fails in one of a few recognizable ways. It attaches AdministratorAccess because a deploy failed with an access-denied error. It writes actions: ["*"] “for now”. It calls a CDK method that doesn’t exist, or invents a CloudFormation resource type. Or it fixes one package by quietly editing forty files, including the lockfile and the CI workflow. Reviewers can’t reliably catch all of that by reading at volume. A short set of mandatory CI checks can, and each check here was run against a real CDK app while writing this.
⚡ TL;DR
- Gate 1, blast radius: cap files per PR; changes to
infra/, workflows and CI scripts need a human label and a code owner.- Gate 2, compile:
tsccatches invented CDK methods;cdk synthmust succeed without AWS credentials.- Gate 3,
cfn-linton the synthesized templates catches invented resource types and invalid values such as a nonexistent Lambda runtime.- Gate 4, a small template gate fails on wildcard actions,
Resource: "*"on write actions, admin managed policies, world-open ingress and resource types you haven’t approved.- Put the same rules in
.cursor/rulesso the agent runs the checks before it opens the PR.
Who this is for: teams letting coding agents open PRs against an AWS CDK (TypeScript) repository. The checks run on CloudFormation, so they also apply to CDK in other languages. Tested with aws-cdk-lib 2.x, CDK CLI 2.1145, TypeScript 7.0, cfn-lint 1.57 and actionlint 1.7.
Why check the synthesized template, not the source
Older versions of this guide used regular expressions over TypeScript source to spot "*" in policies. That misses most real cases: a wildcard built from a variable, a grant helper that expands to broad actions, or a policy added by a construct library. What deploys is the CloudFormation template that cdk synth writes to cdk.out, so the checks below read that. Source-level rules still belong in the agent’s instructions; enforcement belongs on the output.

Gate 1: Bound the blast radius
Large multi-file refactors are where agents strand a codebase half-migrated. Before looking at any AWS detail, check what the PR touches. This script compares the branch with the base, fails above a file limit, refuses paths agents should never edit, requires a label for sensitive paths, and flags a lockfile that changed without a manifest change.
"""Bound what an agent PR may touch. Usage: blast_radius.py <base-ref> [label ...]"""
import fnmatch
import subprocess
import sys
MAX_FILES = 25
NEEDS_HUMAN = { # path pattern -> label a human must add
"infra/*": "infra-approved",
".github/workflows/*": "ci-approved",
"ci/*": "ci-approved",
}
NEVER = ["*/generated/*", "*.pem", ".env*"]
def changed_files(base: str) -> list[str]:
out = subprocess.run(["git", "diff", "--name-only", f"{base}...HEAD"],
capture_output=True, text=True, check=True).stdout
return [f for f in out.splitlines() if f]
def check(files: list[str], labels: set[str]) -> list[str]:
errors = []
if len(files) > MAX_FILES:
errors.append(f"{len(files)} files changed (max {MAX_FILES}); split the PR into layers")
for f in files:
if any(fnmatch.fnmatch(f, pat) for pat in NEVER):
errors.append(f"{f}: agents may not change this path")
for pat, label in NEEDS_HUMAN.items():
if fnmatch.fnmatch(f, pat) and label not in labels:
errors.append(f"{f}: needs label '{label}' from a human reviewer")
lock_changed = any(f.endswith(("package-lock.json", "pnpm-lock.yaml")) for f in files)
if lock_changed and not any(f.endswith("package.json") for f in files):
errors.append("lockfile changed without a package.json change")
return errors
if __name__ == "__main__":
errors = check(changed_files(sys.argv[1]), set(sys.argv[2:]))
print("\n".join(errors) or "blast_radius: OK")
sys.exit(1 if errors else 0)Run against a test branch that edited infra/lib/s.ts and the lockfile only, it fails twice, and the label clears only the infra error:
$ python ci/blast_radius.py main
infra/lib/s.ts: needs label 'infra-approved' from a human reviewer
lockfile changed without a package.json change
$ python ci/blast_radius.py main infra-approved
lockfile changed without a package.json changeA label alone isn’t a strong control, since anyone with triage access can add one. Pair it with CODEOWNERS and the branch protection setting “Require review from Code Owners”, so a platform engineer must approve changes under those paths:
# Agent PRs touching these paths need a platform reviewer (enable "Require review from Code Owners").
/infra/ @acme/platform
/.github/workflows/ @acme/platform
/ci/ @acme/platformGate 2: Typecheck and synth
An invented API fails the TypeScript compiler before anything else runs. With a made-up bucket.grantMagic(fn) in the stack, tsc reports:
lib/hallucinated.ts(7,10): error TS2339: Property 'grantMagic' does not exist on type 'Bucket'.Then cdk synth must succeed. Run it in a job with no AWS credentials. If synth needs to look up a VPC or an account value, commit the result to cdk.context.json so CI doesn’t need account access. The stack used for the tests:
import { Stack, StackProps, Duration } from "aws-cdk-lib";
import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
import * as iam from "aws-cdk-lib/aws-iam";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { Construct } from "constructs";
export interface OrdersStackProps extends StackProps { agentVariant?: boolean }
export class OrdersStack extends Stack {
constructor(scope: Construct, id: string, props: OrdersStackProps = {}) {
super(scope, id, props);
const table = new dynamodb.Table(this, "Orders", {
partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
});
const fn = new lambda.Function(this, "GetOrder", {
runtime: lambda.Runtime.PYTHON_3_12, handler: "index.handler",
code: lambda.Code.fromInline("def handler(e, c):\n return {}"),
timeout: Duration.seconds(5),
});
table.grantReadData(fn); // least privilege: scoped to this table
if (props.agentVariant) {
// What an agent writes "to unblock" a failing deploy:
fn.addToRolePolicy(new iam.PolicyStatement({ actions: ["*"], resources: ["*"] }));
fn.role!.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess"));
}
}
}Gate 3: cfn-lint on cdk.out
A low-level CfnResource with an invented type, or a property value that doesn’t exist, compiles and synthesizes fine. cfn-lint checks templates against the CloudFormation resource specification. With an invented AWS::Lambda::FunctionCache resource and a python3.99 runtime added to the template:
E3006 Resource type 'AWS::Lambda::FunctionCache' does not exist in 'us-east-1'
E3030 'python3.99' is not one of ['dotnet10', 'dotnet6', ... 'python3.12', 'python3.13', ...]CDK output also produces a harmless warning (W3005, a dependency already enforced by GetAtt), so run it as cfn-lint --non-zero-exit-code error. The clean template then exits 0 and the broken one exits non-zero.
Gate 4: Fail on AWS-shaped mistakes
cfn-lint checks validity, not judgment. A wildcard policy is perfectly valid CloudFormation. This gate reads every synthesized template and fails on the patterns agents reach for when a deploy is blocked:
"""Fail agent PRs on AI-shaped AWS mistakes in synthesized CloudFormation (cdk.out/*.template.json)."""
import glob
import json
import sys
ALLOWED_TYPES = {
"AWS::CDK::Metadata", "AWS::DynamoDB::Table", "AWS::IAM::Role", "AWS::IAM::Policy",
"AWS::Lambda::Function", "AWS::Logs::LogGroup", "AWS::SQS::Queue", "AWS::S3::Bucket",
"AWS::S3::BucketPolicy", "AWS::EC2::SecurityGroup",
}
ADMIN_POLICIES = ("AdministratorAccess", "PowerUserAccess", "IAMFullAccess")
def as_list(x):
return x if isinstance(x, list) else [x]
def flat(x) -> str:
return json.dumps(x) # Fn::Join / Ref structures searched as text
def check_statements(rid, statements, hits):
for st in as_list(statements):
if st.get("Effect") != "Allow":
continue
actions = [a for a in as_list(st.get("Action", [])) if isinstance(a, str)]
resources = as_list(st.get("Resource", []))
if any(a == "*" or a.endswith(":*") for a in actions):
hits.append(f"{rid}: wildcard action {actions}")
if "*" in resources and any(not a.endswith(("Describe*", "List*", "Get*")) for a in actions):
hits.append(f"{rid}: Resource '*' with {actions}")
if flat(st.get("Principal", "")) in ('"*"', '{"AWS": "*"}'):
hits.append(f"{rid}: Principal '*'")
def scan(template: dict) -> list[str]:
hits = []
for rid, res in template.get("Resources", {}).items():
rtype, props = res.get("Type", ""), res.get("Properties", {})
if rtype not in ALLOWED_TYPES:
hits.append(f"{rid}: resource type {rtype} needs human review")
if rtype in ("AWS::IAM::Policy", "AWS::IAM::ManagedPolicy", "AWS::S3::BucketPolicy"):
check_statements(rid, props.get("PolicyDocument", {}).get("Statement", []), hits)
if rtype == "AWS::IAM::Role":
for pol in props.get("Policies", []):
check_statements(rid, pol["PolicyDocument"].get("Statement", []), hits)
for arn in props.get("ManagedPolicyArns", []):
if any(f"policy/{name}" in flat(arn) for name in ADMIN_POLICIES):
hits.append(f"{rid}: managed policy {flat(arn)}")
if rtype == "AWS::EC2::SecurityGroup":
for rule in props.get("SecurityGroupIngress", []):
if rule.get("CidrIp") == "0.0.0.0/0" or rule.get("CidrIpv6") == "::/0":
if rule.get("FromPort") not in (80, 443):
hits.append(f"{rid}: world-open ingress on port {rule.get('FromPort')}")
return hits
if __name__ == "__main__":
out_dir = sys.argv[1] if len(sys.argv) > 1 else "cdk.out"
hits = []
for path in glob.glob(f"{out_dir}/*.template.json"):
hits += [f"{path} :: {h}" for h in scan(json.load(open(path)))]
print("\n".join(hits) or "template_gate: OK")
sys.exit(1 if hits else 0)Against the normal stack, which uses table.grantReadData(fn), it passes. Against the variant that adds actions: ["*"] and AdministratorAccess “to unblock” the deploy:
$ python ci/template_gate.py infra/out-good
template_gate: OK
$ python ci/template_gate.py infra/out-bad
Orders.template.json :: GetOrderServiceRoleD549F451: managed policy {"Fn::Join": ["", ["arn:", {"Ref": "AWS::Partition"}, ":iam::aws:policy/AdministratorAccess"]]}
Orders.template.json :: GetOrderServiceRoleDefaultPolicy9E15AD89: wildcard action ['*']
Orders.template.json :: GetOrderServiceRoleDefaultPolicy9E15AD89: Resource '*' with ['*']The resource type allowlist is deliberate: when an agent adds a type nobody on the team has used before, a person should look at it, even if it’s real. For deeper policy checks, IAM Access Analyzer’s ValidatePolicy API reviews a policy document against IAM best practices and grammar; it needs AWS credentials, so run it in a separate job with a narrowly scoped role.
Wire it into one workflow
name: agent-pr-gates
on:
pull_request:
types: [opened, synchronize, reopened, labeled]
permissions:
contents: read
jobs:
gates:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Blast radius
env:
BASE: ${{ github.base_ref }}
LABELS: ${{ join(github.event.pull_request.labels.*.name, ' ') }}
run: python ci/blast_radius.py "origin/$BASE" $LABELS
- name: Typecheck and synth (no AWS credentials in this job)
working-directory: infra
run: |
npm ci
npx tsc -p .
npx cdk synth -q -o cdk.out
- name: CloudFormation lint
run: |
pip install cfn-lint
cfn-lint --non-zero-exit-code error infra/cdk.out/*.template.json
- name: AWS-shaped mistakes
run: python ci/template_gate.py infra/cdk.outTwo details matter. The workflow has only contents: read and no AWS credentials, so nothing in a PR can reach an account. And the base branch name goes through an environment variable instead of being expanded directly in the script, which is GitHub’s recommended way to avoid script injection. The file passes actionlint.
Tell the agent the same rules
Cursor project rules live in .cursor/rules as .mdc files whose frontmatter sets description, globs and alwaysApply; a plain AGENTS.md works too. A rule scoped to infra/ lets the agent fail locally instead of in CI:
---
description: AWS CDK changes in infra/
globs: infra/**/*.ts
alwaysApply: false
---
- Use grant methods on the resource (table.grantReadData(fn)); never actions "*" or resources "*".
- Never attach AdministratorAccess, PowerUserAccess or IAMFullAccess.
- Do not hard-code account IDs or regions; use Stack.of(this).account / .region.
- Only use constructs and methods that exist in the installed aws-cdk-lib; run `npx tsc -p infra` to check.
- Before finishing, run: cd infra && npx tsc -p . && npx cdk synth -q && python ../ci/template_gate.py cdk.out
- Changes under infra/ need a human label; say so in the PR description instead of working around it.Land refactors in layers
For large changes, ask the agent for a sequence of PRs rather than one: new API alongside the old, then callers, then removal. The old export stays as a thin adapter until nothing calls it, so each PR builds and passes the gates on its own:
// New API lands first; the old export stays as a thin adapter until every caller has moved.
export interface Session { userId: string; expiresAt: Date }
export function getSession(token: string): Session {
return { userId: token.slice(0, 8), expiresAt: new Date(Date.now() + 3_600_000) };
}
/** @deprecated Use getSession(). Removed in a separate PR once no callers remain. */
export function getUser(token: string): { id: string } {
return { id: getSession(token).userId };
}That keeps every PR under the file limit, and if one layer is wrong you revert it without unwinding the rest.
Worked example: the agent that “fixed” a deploy
- A developer asks a Cloud Agent to add a reports bucket. The deploy in the agent’s environment fails with an access-denied error, and the agent adds
AdministratorAccessto the Lambda role. - Gate 1 fails: the PR changes
infra/without theinfra-approvedlabel. - Once a platform engineer adds the label to look closer, Gate 4 fails on the managed policy and the wildcard statement.
- The engineer replies with the fix (use
bucket.grantWrite(fn)); the agent pushes again, all four gates pass, and the code owner approves. - The original access-denied error still needs its own fix, in the agent environment’s permissions rather than in the stack.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
cdk synth fails in CI only | Context lookups need AWS credentials | Commit cdk.context.json; keep CI credential-free |
| cfn-lint fails on clean CDK output | Warnings counted as failures | Use --non-zero-exit-code error |
| Template gate flags read-only actions | Resource: "*" with actions outside Describe/List/Get | Scope the resource, or adjust the read-only list deliberately |
| Gate passes but the PR is still risky | New resource type in the allowlist, or a broad resource policy built from tokens | Review allowlist additions; add Access Analyzer validation |
| Agent keeps editing workflows | Rules don’t mention them | Add the path to the rule file and to NEVER/NEEDS_HUMAN |
Checklist
- ☐ File limit, path rules and lockfile check on every agent PR
- ☐ CODEOWNERS and “Require review from Code Owners” on infra, workflows and CI scripts
- ☐
tscandcdk synthin a credential-free job - ☐
cfn-lint --non-zero-exit-code erroroncdk.out - ☐ Template gate for wildcards, admin policies, open ingress and unapproved types
- ☐ Same rules in
.cursor/rulesorAGENTS.md - ☐ Large refactors as layered PRs with adapters
FAQ
Are these checks Cursor-specific?
No. They run on the PR, so they work for any agent or human. Only the rule file format is Cursor’s.
Why not let the agent deploy to a sandbox account instead?
You can, with a tightly scoped sandbox. The gates still matter, because the same template later goes to production.
Doesn’t cdk-nag do this?
cdk-nag is a good addition for rule packs run inside synth. The template gate here is small on purpose, so the team can read and change every rule.
Should agent PRs ever touch IAM?
Yes, through grant methods on resources, reviewed by a code owner. What they shouldn’t do is widen permissions to make an error go away.
Official documentation
- Cursor Cloud Agents and Cursor rules
- AWS CDK permissions and grants and CDK security best practices
- cfn-lint
- IAM Access Analyzer policy validation
- GitHub code owners
Related on CheatCoders: Secret-aware context filters · Spec-first AI development
Last updated on October 10, 2026
Watch: 100 Days of AI on YouTube
Short videos from CheatCoders, one AI engineering topic per day.
Most viewed
- Python Decorators Explained: From Simple Wrappers to Production Patterns
- Claude Code vs Cursor vs GitHub Copilot: Choose by Workflow, and Set Up One Repo That Works With All Three
- AI Agent Frameworks in 2025: LangGraph vs CrewAI vs AutoGen vs Raw API
- Python String Methods: Every str Method With Real Production Examples
- Css Animation Performance: Performance-Optimized Motion for Modern UIs
Newly added
- AWS Systems Manager Parameter Store: Hierarchical Runtime Config for Coding Agents
- Amazon Cognito Identity Pools: Federate Coding-Agent Tool Callers Without Long-Lived Keys
- Amazon Athena for Coding Agents: Query Tool Audit Logs and Eval Traces on S3 Without a Warehouse
- AWS Batch for Coding Agents: Parallel Overnight PR Reviews and Long-Running Jobs Without Lambda Timeouts
- Amazon Bedrock Prompt Management: Version and A/B Test Coding-Agent System Prompts
Deep-dive PDF
Get the expanded guide for this post — extra diagrams-style checklists, failure modes, and a production walkthrough. Free when you subscribe to CheatCoders.
Already subscribed? or open the subscribe page.
Discover more from CheatCoders
Subscribe to get the latest posts sent to your email.