Skip to the content.

Writing constraints

The constraint file format, metadata vocabulary, authority policy, and environment mapping.

← back to the README

Writing constraints

A per-intent constraint (../data/constraints.example.yaml) has every field below. id is a free-form string; every other field feeds either matching, provenance, or authority:

- id: no-scale-prod-peak
  provider: kubernetes            # matches Intent.provider
  resource_pattern: deployment/*  # fnmatch glob against Intent.resource
  actions: [scale]                # any of these matches Intent.action
  scope: {namespace: prod}        # every key must match intent.metadata (or metadata.env)
  time_window:                    # optional; omit or null to apply at all times
    days: [Mon, Tue, Wed, Thu, Fri]
    start: "09:00"
    end: "17:00"
    tz: America/New_York
  rate_limit:                     # optional; only fires once the ledger shows quota used
    max: 3
    per: 1h                       # "<n>m" / "<n>h" / "<n>d"
    key: [namespace]              # bucket the quota per distinct value of these metadata keys
  effect: BLOCK                   # BLOCK | ESCALATE | ALLOW-adjacent (BLOCK/ESCALATE only fire)
  constraint_class: scaling       # must be in the asserting principal's authority set
  principal: sre_lead             # who asserted this constraint
  source_ref: jira-1001           # id used to re-fetch the source for --sources verification
  source_timestamp: "2026-01-15T09:00:00+00:00"
  rule_text: "Do not scale deployments in prod during business hours."
  provenance_hash: f3af697890dc757effcf13cdfcc9e4624e32aa65dd3114c348674ddcda0f2244

A plan-level constraint (../data/plan_constraints.example.yaml) carries exactly one batch predicate instead of a per-intent match, evaluated over every intent from one plan/invocation:

plan_constraints:
- id: plan-max-25-resources
  max_intents: 25                 # escalate/block if the plan has more than N intents
- id: plan-no-db-deletes
  max_matching:                   # escalate/block if more than `max` intents match this filter
    actions: [delete, replace]
    resource_pattern: aws_db_instance.*
    max: 0
- id: plan-no-delete-and-create-us-east-1
  forbid_together:                # escalate/block if the plan matches ALL of these filters
  - {actions: [delete], scope: {region: us-east-1}}
  - {actions: [create]}
# requires_all: same shape as forbid_together, but the effect fires when NOT all are present
- id: plan-k8s-delete-ratio
  ratio:                          # escalate/block if numerator/denominator exceeds `max`
    numerator: {actions: [delete]}
    denominator: {}               # {} matches every intent in the plan
    max: 0.5

Kubernetes resource names

A kubectl target is normalised to <singular kind>/<name> before matching, so write resource_pattern in that form: pod/api-0, node/*, storageclass/fast, clusterrolebinding/*. Plural and short kind names (pods, po, sc, storageclasses, crd, netpol, …) are all mapped to the singular. Two families of verbs name their target implicitly:

(Before v0.2.1 these produced api-0 and node1/*, and plural kinds such as storageclasses were kept plural, so a rule written for the normal form missed them. Rules written against the old forms need updating.)

--as, --as-group and --as-uid are recorded in params.impersonate, and each impersonated identity also becomes an intent of its own, impersonate on user/<name>, group/<name> or uid/<id> — so kubectl --as admin delete node w1 is two intents, the delete and impersonate user/admin. The example policy blocks every impersonation (block-kubectl-impersonation, resource_pattern: "*", actions: [impersonate]).

Metadata vocabulary

Every parser in aegis_core.parser fills Intent.metadata with operator-derived context (not agent-controlled — see the scope semantics below) parsed from the argv itself. aegis_core.environments fills in the rest (env, and any of context/cluster/project/account/subscription/ resource_group/workspace the argv didn’t already carry) from environments.yaml, recording which ones it filled in metadata["resolved_from_environment"]. This table is generated by grepping metadata["..."] = ... in parser.py, not written from memory:

Key Emitted by Example value
namespace kubectl (-n/--namespace), helm (-n/--namespace), flux (-n/--namespace) prod
context kubectl (--context), helm (--kube-context), argocd (--server/context resolution), flux (--context) gke_acme_prod
cluster kubectl (--cluster) prod-us-east
kubeconfig accepted as a global flag by kubectl/helm/flux/argocd so it never leaks into resource/action, but its value is discarded — not recorded in metadata or params by any parser (not recorded)
region terraform (from values/planned_values/provider_config), aws (--region), az (-l/--location), gcloud (--region, or derived from --zone — see below) us-east-1
zone gcloud (--zone) us-east1-b
project gcloud (--project) acme-prod
account not emitted by any parser directly; resolved only via EnvironmentMap/--resolve-current-context (AWS_PROFILE) 123456789012
profile aws (--profile) prod-admin
subscription az (--subscription) 00000000-0000-0000-0000-000000000000
resource_group az (--resource-group/-g, and parsed out of an ARM --ids path) rg-prod
repo gh (--repo/-R, or parsed from a .../repos/<owner>/<name>/... API path) acme/infra
ref gh (--ref, or parsed from a workflow-dispatch API path) main
env never emitted by a parser; set by EnvironmentMap.apply() from environments.yaml when a resolvable key (context/cluster/project/account/subscription/resource_group/workspace) is present and mapped prod
workspace never emitted by a parser; only resolved through environments.yaml’s terraform_workspaces table prod
tool terraform (terraform or opentofu, from plan_json) opentofu
type_name terraform (module-stripped <type>.<name>, e.g. aws_db_instance.main from module.app.aws_db_instance.main) aws_db_instance.main
plan_sha256 terraform (plan_digest() of the plan JSON that was checked) f3af6978...
resolved_from_environment EnvironmentMap.apply() — the list of metadata keys it filled in that the parser hadn’t already set ["env"]

kubeconfig is listed because it’s a common source of confusion: it’s consumed like every other global flag (so it never becomes a bogus resource/action) but has no scope-matching use today, so no parser records it.

Scope matching semantics

A constraint’s scope: {...} (and a plan selector’s scope) is matched against an intent by aegis_core.store.scope_matches:

Authority policy

../data/authority.example.yaml maps each principal to the constraint classes it may assert:

principals:
  admin: [scaling, deletion, configuration]
  sre_lead: [scaling, configuration]
  developer: [configuration]

A constraint whose principal isn’t authorized for its own constraint_class is discarded at decision time with reason unauthorized — even if it was authorized when it was first added (ConstraintStore.add_constraint checks this too, but authority can be revoked later).

Environment mapping

Constraints can scope on env: prod|staging|dev instead of repeating every raw context/account/project/subscription. ../data/environments.example.yaml maps provider-specific identifiers to a normalised environment, and EnvironmentMap.annotate sets intent.metadata["env"] at parse time (the CLI does this automatically via --environments, defaulting to data/environments.example.yaml when present). Resolution is provider-agnostic: whatever tool produced the intent, every identifier its parser recorded is looked up — kubernetes.contexts / clusters / kubeconfigs (kubectl, helm --kube-context, flux, argocd, KUBECONFIG=…), aws.accounts / profiles, gcp.projects, azure.subscriptions / resource_groups, github.repos (owner/repo from gh -R), argocd.apps (globs on the app name) and terraform.workspaces. An identifier that isn’t in the map resolves to no environment at all — never a default like dev — and, deliberately, kubectl namespaces are never used to infer env, since a namespace is a string the agent (or an attacker poisoning its context) controls.

Unknown is not “not prod”. When a rule scoped on env would otherwise match and the intent has no resolved env, the rule is neither honoured nor dropped: the decision is ESCALATE with the note env-unresolved: <id> (a BLOCK from another rule still outranks it). So kubectl delete pod/x -n dev with no --context escalates against no-delete-in-prod-env instead of sailing through. Plan-constraint selectors with scope: {env: …} behave the same.

--resolve-current-context fills a missing identifier from the invoking environment: the kubeconfig’s current-context (and its cluster) from $KUBECONFIG / ~/.kube/config (parsed, never by running kubectl), $AWS_PROFILE, $AWS_DEFAULT_REGION/$AWS_REGION, $CLOUDSDK_CORE_PROJECT, $AZURE_SUBSCRIPTION_ID, $HELM_NAMESPACE, $ARGOCD_SERVER. An explicit --context on the argv always wins, and the keys that were filled are listed in metadata.resolved_from_environment. It is opt-in because it trusts the invoking environment: whoever controls the process environment controls what Aegis believes the target is.