The agent that can change your infrastructure but never runs terraform apply

, , , ,

A LangGraph agent that proposes infrastructure changes — with a credential model that turns “what happens when it acts unpredictably” into a question with a concrete, testable answer.

If you’ve built or evaluated an agentic system in the last year, you’ve heard some version of the same objection: what happens when it’s wrong, or when it’s wrong? For an agent that only drafts emails, the stakes of that question are low. For an agent that touches infrastructure — the thing that decides whether your S3 buckets are public, whether your IAM policies are sane, whether your database gets deleted — the stakes are the whole point.

“We added a confirmation dialog” isn’t a real answer to that question. A confirmation dialog is a UI decision. It doesn’t change what the underlying credential is capable of doing if the agent is compromised, prompted into a bad action, or just wrong. A real answer has to live one layer down, in the actual permissions the agent’s runtime identity holds — not in whether it asks nicely first.

A confirmation dialog gives youA permission boundary gives you
A UI event the agent’s own code producesA rule enforced below the agent (e.g. by GitHub and AWS)
“The agent asked before acting”“The credential can’t perform the action — asked or not”
Something a prompt-injected or buggy agent can skipSomething no agent behavior can route around

That’s the problem InfraAi is built around. It’s an agentic system that turns a plain text infrastructure request into a validated, human-reviewed Terraform change — and the interesting part isn’t the natural-language interface. It’s that the agent’s runtime identity is structurally incapable of applying anything, regardless of what it decides to do.


What InfraAi actually does

You describe a change — “add versioning to the app_data bucket” — and a small team of LangGraph-orchestrated agents takes it from there:

  1. Context agent reads your Terraform repo over the GitHub API — resources, variables, naming conventions — without cloning it.
  2. Planner agent classifies the request as a change, a question, or something ambiguous. Only a change continues; a question gets answered directly from repo context, and an ambiguous request comes back as a clarifying question in the same thread.
  3. Editor agent writes the minimal diff for the plan, grouped by AWS service (s3.tf, iam.tf, …).
  4. Validator agent runs terraform validate, then terraform plan against a real (but sandboxed) AWS account.
  5. Security + cost agent runs a Checkov scan and an Infracost delta against the repo’s budget ceiling — informational, never blocking.
  6. PR agent opens a branch and a pull request with the diff, the plan output, the findings, and the cost delta.

New to Terraform? validate checks a config is syntactically and internally consistent — no cloud calls. plan reads real cloud state and prints the diff it would make; it changes nothing. apply carries out a plan. destroy tears down everything a config manages. InfraAi’s agent only ever runs the first two.

You approve by merging. That’s it. terraform apply never runs here — it runs in the target repo’s own CI, on a human’s manual trigger, with credentials this codebase never holds. And this isn’t a diagram of an intention: the whole path has run end to end against a live sandbox account — a request to add a versioned S3 bucket with a lifecycle rule went request → plan → validate → scan → PR → merge → CI plan → CI apply, with the bucket confirmed in AWS afterward.

A pull request opened by the PR agent — the request, the terraform plan line, the Checkov findings, the cost delta, and the diff

Not every request is a change. The Planner classifies each one as a change, a question, or ambiguous, and only a change runs the rest of the team. A question (“what instance type is the app server?”) is answered straight from the repo context — no diff, no plan, no PR. An ambiguous request comes back as a clarifying question in the same thread rather than a guessed-at plan. Declining to act on a vague request is part of the safety story too: there’s more than one point where the system stops and hands control back.

The InfraAi web UI answering a question about existing infrastructure — no PR, no Terraform run

Under the hood it’s a tested state machine, not a prompt chain. The six agents are nodes in a LangGraph graph with explicit conditional routing between them — state.py is one TypedDict, graph.py is the wiring.


The three-layer safety model

Three boundaries sit between the agent and your infrastructure. Two of them are enforced by systems the agent can’t control — GitHub and AWS — and the third is a backstop re-checked on every commit.

Layer one: writes are always a branch and a PR, never a push. InfraAi’s codebase never contains the Terraform it manages — it’s a separate target repo, protected by GitHub’s own branch protection rules requiring a PR and a review before merge. “PR-gated” isn’t a promise the agent makes about its own behavior; it’s enforced by GitHub, one level above anything the agent could bypass.

Layer two: read-only everywhere, with an explicit Deny as a backstop. The Validator’s plan-only role — infrai-plan-role in this repo, infrai-target-plan-role in the target’s CI — is the whole policy below (S3-scoped here because that’s all the demo touches; the shape is the same for any service):

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ReadOnlyAccess",
"Effect": "Allow",
"Action": ["s3:Describe*", "s3:Get*", "s3:List*"],
"Resource": "*"
},
{
"Sid": "DenyMutatingActions",
"Effect": "Deny",
"Action": ["s3:Create*", "s3:Update*", "s3:Delete*", "s3:Put*"],
"Resource": "*"
}
]
}

That’s not “we didn’t grant write access.” It’s “we granted read access and affirmatively denied write access” — which matters because in AWS IAM an explicit Deny always wins over any Allow, anywhere, even one added by mistake later in a different policy attached to the same principal.

And because policy drift is the realistic failure mode, that Deny is enforced by a test rather than trusted. tests/test_iam_policies.py loads every read-only policy document InfraAi ships and fails if any Allow names a mutating verb — or if the backstop Deny is missing:

_MUTATING_PREFIXES = ("Create", "Update", "Delete", "Put")
def test_read_only_role_has_no_mutating_allow_actions(policy_path):
policy = json.loads(policy_path.read_text())
for action in _actions(policy, "Allow"):
assert not _verb(action).startswith(_MUTATING_PREFIXES), (
f"{policy_path.name} allows mutating action: {action}"
)

The safety property isn’t configured once and then trusted forever; it’s re-checked on every commit like any other piece of application logic. A companion suite (tests/test_target_repo_templates.py) does the same for the shipped CI workflows — asserting, among other things, that the automatic plan job never even has the apply role’s ARN in scope.

Layer three: exactly one apply-capable role, and it’s not reachable from here. infrai-apply-role is the only identity in the whole system that can mutate infrastructure. It’s reachable only via OIDC from the target repo’s apply.yml — a workflow_dispatch-only job, pinned to main, gated behind the production GitHub Environment. No static AWS keys exist anywhere in this system. Instead, the token exchange looks like this:

AWS never sees a GitHub credential and GitHub never sees an AWS credential; the only thing that crosses is that JWT, and it’s signed, scoped, and gone within after the job finishes. Nothing to leak, nothing to rotate.

The part I like most is what the trust policy pins — a literal string match on two claims:

"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub":
"repo:<OWNER_NAME>@<OWNER_ID>/<REPO_NAME>@<REPO_ID>:environment:production"
}
}

That sub isn’t just repo:org/repo-name — it embeds the numeric, immutable owner and repo IDs:

That closes a real weakness: a trust policy keyed only on names could be hijacked by a repo rename or an ownership transfer — someone else takes the newly-available name and gets the role along with it. This one can’t be; it doesn’t trust the repo’s name at all.

Put together: the agent can propose almost anything. It can never apply anything. Those two facts are enforced by two completely different mechanisms — GitHub branch protection for the first, IAM plus OIDC for the second — so an agent prompted into bad behavior, or just wrong, runs into a wall that has nothing to do with whether it “decided” to behave.


The escalation path

The Validator loop is where the interesting judgment call lives: what happens when the agent isn’t confident its own output is correct?

def route_after_validator(state: InfraAIState) -> str:
if state["validation_result"].get("valid"):
return "pass"
if state.get("retry_count", 0) >= 3:
return "escalate"
return "retry"

A failed terraform validate or terraform plan sends the diff back to the Editor. The Editor is re-invoked with the specific errors from the failed attempt injected directly into its prompt, alongside the rejected files themselves, with instructions to fix those problems specifically. The first pass has no error context at all; every prompt after a failure does. It’s a targeted fix, not a blind retry.

After three failed cycles, the graph stops and hands off to a human — and the handoff carries the actual Terraform errors, not just a status flag:

def escalate(state):
errors = validation_errors(state.get("validation_result", {}))
detail = "\n".join(f"- {e}" for e in errors) if errors else "- (no specific terraform error was captured)"
return {
"status": "needs_human",
"agent_message": (
"I couldn't reach a valid Terraform plan after 3 attempts, so I'm handing this "
f"to a human. The last attempt failed with:\n\n{detail}\n\n"
"Start a new conversation with a more specific request."
),
}
The escalation path in the InfraAi UI — the retry counter climbing to 3/3 in the run log, then the handoff message

(The failure here was injected to exercise the path without a genuinely broken request.)

Three is a deliberate middle ground, not an arbitrary number. Zero retries means every transient failure — an API call that randomly fails, a brief sandbox glitch — escalates immediately, and a human becomes a bottleneck for problems the agent could have fixed itself. Infinite retries means the agent can burn arbitrary time and tokens chasing a fix, with no guarantee it ever settles on one, and no guarantee that whatever eventually “passes” is actually right. A capped, context-aware retry loop sits between those two failure modes: the agent gets a few real shots at self-correction, informed by exactly what went wrong last time, and then it stops and says so.

This is what makes calling it human-in-the-loop more than a marketing word. The human checkpoint isn’t scheduled — it isn’t “always ask before doing anything.” It’s triggered by the system’s own signal that something is going wrong, which is a meaningfully different (and harder to fake) kind of safety property.

One consequence worth calling out: because the retry loop is bounded and the PR agent is idempotent — the branch is keyed to the conversation, and a re-run updates the existing PR instead of opening a second one — re-running a request that failed or half-finished is safe. You don’t end up with a pile of near-duplicate PRs or a partially-applied change to reconcile. “Run it again” is always a valid recovery step, which is the difference between a system a human supervises and one a human has to babysit.


What this cost in complexity

None of this was free, and it’s worth naming the costs honestly rather than pretending the design was all benefits.

Two repos instead of one — enforces the can’t-apply boundary structurally, but every target repo needs its own copy of CI workflows, IAM policy, and config (no central maintenance).

A separate sandbox AWS account for a powerless role — the plan-only role is already locked to Describe/Get/List with Deny on everything else; isolating it in its own account is extra infrastructure to provision and maintain, in service of blast-radius reduction that was arguably already achieved.

Hand-bootstrapped Terraform state — the S3 state bucket has to be created separately, since Terraform can’t manage the backend it stores its own state in.

The PR agent’s identity is the weak link — it runs as a person’s own gh CLI session, not a scoped GitHub App. Unlike every other identity in the system, there’s no precise policy or test enforcing what it can do — just whatever that person’s account can do. Acknowledged as unfinished.


Why it was worth it

The biggest objection to giving agents real infrastructure access is a fair one, and “we added a confirmation step” has never actually answered it. What does answer it is being able to point at one specific credential and say exactly what it can and can’t do — with a test that breaks the moment that stops being true. InfraAi’s agent can read almost anything and suggest almost anything — but there is exactly one role in the whole system that can actually change infrastructure, and no code this agent runs can reach it. Everything else — the retry loop, the escalation, the PR — exists to make that one boundary the only one that has to hold.


References

Leave a Reply