Protect (PR) · Blast radius
Blast radius at the server side layer
external · Outside the agent entirely
If the agent decides to violate this concern, what stops it at this layer?
What this cell does
Gated IaC apply pipeline, ResourceQuota, NetworkPolicy default-deny, prod/non-prod separation, immutable backups, PDB.
Artifacts (7)
cross-account-providers.tfview on GitHub# ABOUTME: Terraform provider blocks demonstrating cross-account separation: agent in non-prod, prod in a separate account.
# ABOUTME: The agent role explicitly cannot AssumeRole into prod; production work happens through human-operated pipelines only.
terraform {
required_version = ">= 1.13.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
# ---- Non-prod account (where the agent's role lives) ----
provider "aws" {
alias = "nonprod"
region = "us-east-1"
profile = "nonprod-admin" # operator credential, not the agent's
}
# ---- Prod account (where prod resources live) ----
provider "aws" {
alias = "prod"
region = "us-east-1"
assume_role {
role_arn = "arn:aws:iam::PROD_ACCOUNT_ID:role/terraform-apply-prod"
session_name = "terraform-${formatdate("YYYYMMDDhhmmss", timestamp())}"
# external_id is recommended for cross-account roles to prevent the
# confused deputy problem.
external_id = var.prod_external_id
}
}
variable "prod_external_id" {
description = "External ID required by the prod account's trust policy."
type = string
sensitive = true
}
# ---- The agent's role lives in nonprod and CANNOT AssumeRole into prod ----
# The trust policy permits AssumeRoleWithWebIdentity only via the EKS OIDC
# issuer for the non-prod cluster, scoped to a specific ServiceAccount.
resource "aws_iam_role" "claude_code" {
provider = aws.nonprod
name = "claude-code"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRoleWithWebIdentity"
Principal = { Federated = aws_iam_openid_connect_provider.nonprod_eks.arn }
Condition = {
StringEquals = {
"${replace(aws_iam_openid_connect_provider.nonprod_eks.url, "https://", "")}:sub" = "system:serviceaccount:agent-claude-prod:claude-code"
"${replace(aws_iam_openid_connect_provider.nonprod_eks.url, "https://", "")}:aud" = "sts.amazonaws.com"
}
}
}]
})
}
# Explicit deny on the agent's role: it cannot AssumeRole into the prod account.
# This deny is in addition to the absence of an allow; deny-overrides-allow in IAM.
resource "aws_iam_role_policy" "claude_code_no_prod" {
provider = aws.nonprod
name = "no-prod-assume-role"
role = aws_iam_role.claude_code.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Deny"
Action = ["sts:AssumeRole", "sts:AssumeRoleWithWebIdentity", "sts:AssumeRoleWithSAML"]
Resource = [
"arn:aws:iam::PROD_ACCOUNT_ID:role/*"
]
}]
})
}
# (the OIDC provider definition for nonprod_eks lives elsewhere in this module)
resource "aws_iam_openid_connect_provider" "nonprod_eks" {
provider = aws.nonprod
url = var.nonprod_eks_oidc_url
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [var.nonprod_eks_oidc_thumbprint]
}
variable "nonprod_eks_oidc_url" { type = string }
variable "nonprod_eks_oidc_thumbprint" { type = string }
iac-gated-pipeline.ymlview on GitHub# ABOUTME: GitHub Actions workflow with plan-and-apply split. The apply job is gated by a GitHub environment with required reviewers.
# ABOUTME: The DEPLOY_FREEZE repo variable, set by a separate workflow when an incident is active, blocks apply.
name: Terraform (gated apply)
on:
pull_request:
paths: ['infrastructure/**']
push:
branches: [main]
paths: ['infrastructure/**']
permissions:
contents: read
id-token: write # for OIDC AssumeRole
jobs:
plan:
name: terraform plan (read-only)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::123456789012:role/terraform-plan-readonly
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.15.6
- name: terraform fmt
working-directory: infrastructure
run: terraform fmt -check -recursive
- name: terraform init
working-directory: infrastructure
run: terraform init -backend-config=backend.hcl
- name: terraform validate
working-directory: infrastructure
run: terraform validate
- name: terraform plan
working-directory: infrastructure
run: terraform plan -out=tfplan -no-color
- name: archive plan
uses: actions/upload-artifact@v6
with:
name: tfplan
path: infrastructure/tfplan
retention-days: 7
apply:
name: terraform apply (gated)
needs: plan
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment:
name: production # Configure this environment with required reviewers in GitHub settings.
url: https://console.aws.amazon.com
steps:
- name: deploy freeze check
if: vars.DEPLOY_FREEZE == 'true'
run: |
echo "::error::Deployment freeze is active. Apply blocked."
exit 1
- uses: actions/checkout@v6
- uses: actions/download-artifact@v6
with:
name: tfplan
path: infrastructure
- uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::123456789012:role/terraform-apply-prod
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.15.6
- name: terraform init
working-directory: infrastructure
run: terraform init -backend-config=backend.hcl
- name: terraform apply
working-directory: infrastructure
run: terraform apply -auto-approve tfplan
limitrange.yamlview on GitHub# ABOUTME: LimitRange providing per-container defaults so pods without explicit resources still respect ResourceQuota.
# ABOUTME: max values are caps on what any single container in this namespace can request, regardless of pod-level config.
apiVersion: v1
kind: LimitRange
metadata:
name: agent-claude-prod-limits
namespace: agent-claude-prod
spec:
limits:
- type: Container
default:
cpu: 500m
memory: 512Mi
ephemeral-storage: 1Gi
defaultRequest:
cpu: 100m
memory: 128Mi
ephemeral-storage: 100Mi
max:
cpu: "2"
memory: 4Gi
ephemeral-storage: 4Gi
min:
cpu: 50m
memory: 64Mi
- type: PersistentVolumeClaim
max:
storage: 20Gi
min:
storage: 1Gi
networkpolicy-allowlist.yamlview on GitHub# ABOUTME: Explicit allow rules layered on top of default-deny. DNS is required or pods silently fail to resolve names.
# ABOUTME: HTTPS egress is allowed only to non-RFC1918 ranges to keep the agent off the internal network.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: claude-code-egress
namespace: agent-claude-prod
spec:
podSelector:
matchLabels:
app: claude-code
policyTypes:
- Egress
egress:
# 1. DNS to kube-dns (or whatever CoreDNS is labeled in your cluster).
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: TCP
port: 53
- protocol: UDP
port: 53
# 2. HTTPS to public internet only. The except: list strips out RFC1918
# and link-local; the agent cannot reach internal services this way.
# For granular per-host egress, layer a Cilium FQDN policy on top.
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 169.254.0.0/16 # link-local (cloud metadata)
- 100.64.0.0/10 # carrier-grade NAT
ports:
- protocol: TCP
port: 443
networkpolicy-default-deny.yamlview on GitHub# ABOUTME: Namespace-wide default deny on ingress and egress. Apply first, then layer specific allow rules on top.
# ABOUTME: Without a CNI that supports NetworkPolicy (some legacy setups), this object is accepted but does nothing.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: agent-claude-prod
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
resourcequota.yamlview on GitHub# ABOUTME: Namespace-level quota that caps CPU, memory, pod count, PVC count, and forbids LoadBalancer/NodePort services.
# ABOUTME: Pair with the LimitRange so individual pods get sensible defaults; without LimitRange the quota's requests ceiling rejects them.
apiVersion: v1
kind: ResourceQuota
metadata:
name: agent-claude-prod-quota
namespace: agent-claude-prod
spec:
hard:
requests.cpu: "10"
requests.memory: 20Gi
requests.ephemeral-storage: 50Gi
limits.cpu: "20"
limits.memory: 40Gi
limits.ephemeral-storage: 100Gi
pods: "20"
persistentvolumeclaims: "5"
requests.storage: 100Gi
# Block service types that expose the agent's namespace to the public
# network or to external load balancers.
services.loadbalancers: "0"
services.nodeports: "0"
# Block secrets and configmaps growing without bound. Tune for your
# specific agent's actual config-loading pattern.
secrets: "20"
configmaps: "20"
s3-immutable-backups.shview on GitHub#!/usr/bin/env bash
# ABOUTME: Provisions an S3 bucket with Object Lock in COMPLIANCE mode and 30-day default retention for immutable backups.
# ABOUTME: COMPLIANCE mode blocks deletion even by the root account; GOVERNANCE mode allows specific principals to bypass.
set -euo pipefail
BUCKET="${1:-prod-backups-immutable}"
REGION="${AWS_REGION:-us-east-1}"
RETENTION_DAYS="${RETENTION_DAYS:-30}"
MODE="${MODE:-COMPLIANCE}"
if [[ -z "${AWS_PROFILE:-}" ]]; then
echo "Set AWS_PROFILE to a profile with s3:CreateBucket and s3:PutObjectLockConfiguration." >&2
exit 1
fi
# Object Lock requires a versioned bucket created with object-lock enabled at
# creation time. Retrofitting an existing bucket is not supported.
aws --profile "$AWS_PROFILE" s3api create-bucket \
--bucket "$BUCKET" \
--region "$REGION" \
--create-bucket-configuration "LocationConstraint=$REGION" \
--object-lock-enabled-for-bucket
aws --profile "$AWS_PROFILE" s3api put-public-access-block \
--bucket "$BUCKET" \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
aws --profile "$AWS_PROFILE" s3api put-bucket-encryption \
--bucket "$BUCKET" \
--server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
aws --profile "$AWS_PROFILE" s3api put-object-lock-configuration \
--bucket "$BUCKET" \
--object-lock-configuration "{
\"ObjectLockEnabled\": \"Enabled\",
\"Rule\": {
\"DefaultRetention\": {
\"Mode\": \"$MODE\",
\"Days\": $RETENTION_DAYS
}
}
}"
# Versioning is implicit when object lock is enabled at creation, but we set
# it explicitly so a future operator cannot disable it without surfacing the
# action in audit logs.
aws --profile "$AWS_PROFILE" s3api put-bucket-versioning \
--bucket "$BUCKET" \
--versioning-configuration Status=Enabled
echo "Bucket $BUCKET provisioned with Object Lock ($MODE, $RETENTION_DAYS days)."
echo ""
echo "IMPORTANT: the credential that writes backups must be DIFFERENT from the"
echo "credential the agent uses for normal operations. The agent must not have"
echo "s3:PutBucketLifecycleConfiguration on this bucket or it can shorten the"
echo "retention window."
Cell notes
Blast radius / Server-side
Control. Gated IaC apply pipeline as the actual backstop. ResourceQuota and LimitRange per namespace. NetworkPolicy default-deny with explicit allowlist. Physical separation of prod and non-prod clusters. Immutable backups with separate credentials. PodDisruptionBudget on critical workloads.
Strength. Deterministic and external. Bypass requires multiple simultaneous server-side failures: prevent_destroy removed in the same cycle as the apply (only effective when paired with the gated pipeline), DNS exfiltration when DNS is allowed without filtering, "immutable" backups in the same account as the credential that wrote them.
Tooling
- - GitHub Actions / GitLab CI / Jenkins for the pipeline split.
- - Kubernetes ResourceQuota, LimitRange, NetworkPolicy.
- - AWS S3 Object Lock, GCP Bucket Retention Policy, or Azure Blob immutable storage for immutable backups.
- - A second AWS account, GCP project, or Azure subscription for prod, separate from non-prod.
Files in this directory
- -
iac-gated-pipeline.yml, split-stage Terraform pipeline. Theplanjob runs on every PR and push with read-only credentials; theapplyjob runs only onmainpush and requires manual approval via a GitHub environment with required reviewers. Drop in.github/workflows/. - -
networkpolicy-default-deny.yaml, namespace-wide default deny on ingress and egress. - -
networkpolicy-allowlist.yaml, explicit allow rules layered over the default deny: kube-dns, public HTTPS to non-RFC1918 ranges only. - -
resourcequota.yaml, namespace-level quota on CPU, memory, pods, PVCs, LoadBalancer/NodePort services (set to 0). - -
limitrange.yaml, per-container default and max requests/limits. - -
s3-immutable-backups.sh, provisions an S3 bucket with Object Lock in compliance mode and a 30-day default retention. - -
cross-account-providers.tf, Terraform provider blocks demonstrating the cross-account separation pattern: agent role lives in the non-prod account and explicitly cannot AssumeRole into prod.
Verification
# 1. Pipeline split: plan runs without approval, apply does not
gh run list --workflow=iac-gated-pipeline.yml --branch=main --limit 1
# expected: apply job shows "Waiting for review" or "Approved"
# 2. NetworkPolicy default-deny in effect
kubectl run -n agent-claude-prod test --image=alpine --rm -it -- \
wget -O- --timeout=3 http://10.0.0.5
# expected: timeout
# 3. ResourceQuota enforced
kubectl apply -n agent-claude-prod -f - <<EOF
apiVersion: v1
kind: Pod
metadata:
name: test-quota
spec:
containers:
- name: c
image: alpine@sha256:REPLACE_WITH_DIGEST
resources:
requests:
cpu: "100"
memory: 200Gi
EOF
# expected: rejected by ResourceQuota admission
# 4. Immutable backup cannot be deleted
aws s3 rm s3://prod-backups-immutable/test-object
# expected: AccessDenied due to Object Lock
# 5. Cross-account assumption denied
aws --profile claude-code-prod sts assume-role \
--role-arn arn:aws:iam::PROD_ACCOUNT:role/anything \
--role-session-name test
# expected: failure; agent role has no AssumeRole on prod account
Common mistakes
- - Pipeline split where apply auto-runs after plan with no environment gate.
- - NetworkPolicy default-deny without an allow rule for kube-dns; pods cannot resolve names and silently fail.
- - ResourceQuota without LimitRange; new Pods without explicit limits get rejected by the quota's
requestsceiling. - - "Immutable" backups in the same account as the credential that wrote them, with
s3:DeleteObjectVersionands3:PutBucketLifecycleConfigurationavailable. - - Prod and non-prod in the same cluster separated only by namespaces. Namespace boundary is not a security boundary if NetworkPolicy or admission policies have gaps.
Citation
NIST CSF 2.0 PR.IR-01 (networks protected), PR.IR-02 (technology assets protected from environmental threats), PR.IR-03 (mechanisms achieving resilience requirements), PR.IR-04 (adequate resource capacity), PR.DS-11 (backups created, protected, maintained, tested). NIST AI RMF MANAGE 2.4, MANAGE 4.1. OWASP LLM10 (Unbounded Consumption). OWASP ASI05, ASI08 (Cascading Failures). NIST SP 800-160 Vol. 1. NIST SP 800-34 Rev. 1 (contingency planning).
Primary bypasses
Documented, not hypothetical. A control whose bypass is undocumented is worse than no control, because somebody trusted it.
- prevent_destroy edited away then applied
- quota set too generously
- DNS exfiltration when DNS not filtered
Crosswalk
| NIST CSF 2 0 | PR.IR-01, PR.IR-02, PR.IR-03, PR.IR-04, PR.DS-11 |
|---|---|
| NIST AI RMF | MANAGE 2.4, MANAGE 4.1 |
| OWASP LLM | LLM05, LLM10 |
| OWASP AGENTIC | ASI05, ASI08 |
| OTHER | NIST SP 800-160 Vol. 1, NIST SP 800-34 |
Cite this cell:
https://agenticcovenants.com/protect/blast-radius/server-side/