DEV Community

Cover image for I Gave an AI Agent cdk deploy, Then Made Sure It Couldn't Use It
Yuuki Yamashita
Yuuki Yamashita

Posted on

I Gave an AI Agent cdk deploy, Then Made Sure It Couldn't Use It

The first version of this worked exactly as advertised, and that's what worried me. I asked an agent to turn on debug logging for a Lambda function, it ran cdk deploy, and thirty seconds later the config was live. No review, no diff, no second opinion. It did precisely what I asked, which is the problem with agents that can deploy: the failure mode isn't the agent misbehaving, it's the agent doing exactly what a hurried, half-formed request told it to do.

So I built the opposite of that. An agent that can look at infrastructure and propose a change, but that physically cannot make the change happen. The only thing standing between "proposed" and "deployed" is a person clicking a button in Slack.

Two CodeBuild projects, two roles, one shared source

The design question I kept coming back to was where exactly to draw the line. Putting the check in the agent's system prompt was never really an option, since a prompt is an instruction a model can be talked out of, not a boundary. So the split happens at the IAM layer, across two CodeBuild projects that both run against the same CDK app but under service roles with almost nothing in common.

deploy-gate-plan runs cdk diff --method=template, which compares the synthesized template directly against what's deployed, no change set involved. Its role needs cloudformation:GetTemplate, cloudformation:DescribeStacks, and exactly one sts:AssumeRole for the CDK bootstrap lookup role, so ssm.StringParameter.valueFromLookup can resolve a config value at synth time. That's the whole policy. It cannot create a change set, cannot assume the deploy role, cannot assume the file-publishing role.

deploy-gate-execute runs cdk deploy --require-approval never. Its role can assume all three CDK bootstrap roles a real deploy needs. It is the only IAM identity anywhere in this system with a path to CloudFormation write access, and the only thing that can start a build on it is a Lambda that verifies a Slack signature and holds no CloudFormation permission of its own.

The agent runs on the Strands Agents SDK with Claude Sonnet 4.6, and it sits in front of all of this holding the least: permission to write one SSM parameter, start the plan project, read its CloudWatch logs, and post to Slack. That's the entire policy attached to its role. It cannot see deploy-gate-execute exists, in the sense that IAM will refuse it if it tries.

What actually gets deployed

The demo target is deliberately small: one Lambda function whose LOG_LEVEL comes from an SSM parameter, baked into the CDK template with valueFromLookup so a parameter change shows up as a real, visible diff rather than a resolved-at-deploy-time no-op. Ask the agent to turn on debug logging, and the Slack card that shows up looks like this:

[~] AWS::Lambda::Function TargetFunction TargetFunctionBA89AD45
 └─ [~] Environment
     └─ [~] .Variables:
         └─ [~] .LOG_LEVEL:
             ├─ [-] INFO
             └─ [+] DEBUG
Enter fullscreen mode Exit fullscreen mode

Click Approve, and deploy-gate-execute picks up the same SSM value and runs the real deploy. Click Deny, or ignore it, and nothing happens: the SSM parameter already changed, but nothing ever reads it into a running stack. I tested the ignore path by accident more than once while debugging the Slack side, and it held up exactly the way it should. Four separate pending requests sat in DynamoDB, untouched, and none of them could be approved twice, because the status transition is a conditional write, not a flag the Lambda trusts blindly.

The part that actually took the time

None of the IAM design was where I lost the afternoon. That went to three things Slack and Lambda do that I hadn't hit before.

First, Lambda Function URLs created after October 2025 need both lambda:InvokeFunctionUrl and a plain lambda:InvokeFunction grant in the resource policy. I had only the first one, so every request came back 403 before it even reached my code. No logs, nothing, which is its own kind of confusing.

Second, a brand-new Slack app defaults to Socket Mode on, which quietly hides the Interactivity Request URL field. Turning Socket Mode off to get to that field also resets the Interactivity toggle itself back to off, so the natural sequence of disabling Socket Mode and then going to add the URL lands on a page that looks like nothing changed. Two toggles, not one.

Third, and the one that actually took the longest to see: Lambda Function URLs base64-encode application/x-www-form-urlencoded bodies, which is exactly the content type Slack sends interactive payloads as. My signature verification was hashing the base64 wrapper instead of the decoded bytes underneath it, so every single request failed HMAC comparison with a difference that had nothing to do with the signing secret being wrong. I only found it by logging isBase64Encoded and the raw body prefix side by side and noticing the prefix was valid base64 text, not JSON.

None of these are exotic. They're the kind of thing that's obvious in hindsight and invisible while you're staring at a 401 with no other clues, and I'd guess they're the same three things waiting for the next person who wires Slack interactivity straight to a Function URL instead of going through Bolt and Socket Mode.

Why the boilerplate is the point

A single Lambda's log level is not a change worth building this much infrastructure to protect. That was always the point of keeping it small. The same plan/execute split applies just as well to a CDK stack with a dozen resources, or to an agent driving Amazon Q Developer CLI or Kiro instead of a hand-rolled Strands loop. What changes is the blast radius of what's behind cdk deploy. What doesn't change is the shape of the fix: the agent proposes, a human approves, and the permission to actually touch CloudFormation never sits anywhere the model's output could reach on its own.

The code is on GitHub.

Top comments (0)