Cursor Cloud Agents (formerly Background Agents): CI Gates That Catch Hallucinated AWS Changes

23 views

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: tsc catches invented CDK methods; cdk synth must succeed without AWS credentials.
  • Gate 3, cfn-lint on 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/rules so 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.

Figure 1. Four gates on every agent pull request. Each runs without AWS credentials, so a failing PR can't touch an account. Diagram by CheatCoders.
Figure 1. Four gates on every agent pull request. Each runs without AWS credentials, so a failing PR can’t touch an account. Diagram by CheatCoders.

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.

python
"""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:

text
$ 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 change

A 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:

text
# Agent PRs touching these paths need a platform reviewer (enable "Require review from Code Owners").
/infra/              @acme/platform
/.github/workflows/  @acme/platform
/ci/                 @acme/platform

Gate 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:

text
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:

typescript
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:

text
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:

python
"""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:

text
$ 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

yaml
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.out

Two 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:

markdown
---
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:

typescript
// 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

  1. 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 AdministratorAccess to the Lambda role.
  2. Gate 1 fails: the PR changes infra/ without the infra-approved label.
  3. Once a platform engineer adds the label to look closer, Gate 4 fails on the managed policy and the wildcard statement.
  4. The engineer replies with the fix (use bucket.grantWrite(fn)); the agent pushes again, all four gates pass, and the code owner approves.
  5. The original access-denied error still needs its own fix, in the agent environment’s permissions rather than in the stack.

Troubleshooting

SymptomLikely causeFix
cdk synth fails in CI onlyContext lookups need AWS credentialsCommit cdk.context.json; keep CI credential-free
cfn-lint fails on clean CDK outputWarnings counted as failuresUse --non-zero-exit-code error
Template gate flags read-only actionsResource: "*" with actions outside Describe/List/GetScope the resource, or adjust the read-only list deliberately
Gate passes but the PR is still riskyNew resource type in the allowlist, or a broad resource policy built from tokensReview allowlist additions; add Access Analyzer validation
Agent keeps editing workflowsRules don’t mention themAdd 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
  • ☐ tsc and cdk synth in a credential-free job
  • ☐ cfn-lint --non-zero-exit-code error on cdk.out
  • ☐ Template gate for wildcards, admin policies, open ingress and unapproved types
  • ☐ Same rules in .cursor/rules or AGENTS.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

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.

Open the playlist on YouTube · CheatCoders on YouTube

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.

Comments

No comments yet. Why don’t you start the discussion?

Leave a comment

No account needed. Name and email are optional.