Agentic Covenants

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. The plan job runs on every PR and push with read-only credentials; the apply job runs only on main push 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 requests ceiling.
  • - "Immutable" backups in the same account as the credential that wrote them, with s3:DeleteObjectVersion and s3:PutBucketLifecycleConfiguration available.
  • - 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 0PR.IR-01, PR.IR-02, PR.IR-03, PR.IR-04, PR.DS-11
NIST AI RMFMANAGE 2.4, MANAGE 4.1
OWASP LLMLLM05, LLM10
OWASP AGENTICASI05, ASI08
OTHERNIST SP 800-160 Vol. 1, NIST SP 800-34