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:
exec,logs,attachandport-forwardact on a pod by default:kubectl exec api-0ispod/api-0;kubectl exec deploy/webisdeployment/web.drain,cordonanduncordonact on nodes:kubectl drain node1isnode/node1.
(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:
- Metadata-first. Each scope key is looked up in
intent.metadatabeforeintent.params, andparamsmay only supply a keymetadatalacks entirely — an agent-controlled--env=devflag can never shadow an operator-resolvedmetadata["env"] = "prod"(REVIEW-4 T1.5). A key absent from both never matches. - String/bool coercion. If either side of a comparison is a bool, both are read as booleans
(
"true"/"false"/"True"/"FALSE"all count, case-insensitively). Otherwise, if either side is a string, both are compared as strings — so a YAMLaccount: 123456789012(an int) matches an intent’s"123456789012"(a string) and vice versa. - Lists are OR.
scope: {namespace: [prod, prod-eu]}matches either value. - Globs. A scope value containing
*,?, or[...]is matched withfnmatch(case-sensitive) against the intent’s value, e.g.region: us-east-*. - Dotted paths. A scope key with a
.(e.g.set.replicaCount) first tries a literal key of that exact name, then walks the dots into nested dicts (params["set"]["replicaCount"]) — this is what makeshelm --set replicaCount=0(parsed intoparams["set"] = {"replicaCount": 0}) expressible asscope: {set.replicaCount: 0}. actions: ["*"]. A per-intent constraint (or plan selector, whereactionsis already an “any of these” list) may include the literal string"*"in itsactionsset to mean “any action”. It is otherwise an ordinary string: no special validation, and no effect on the constraint’s provenance hash (which hashes theactionsset exactly as given).- Time windows.
time_window.tz(or the store-leveldefault_tz— see below) localises the evaluation instant before every other check.daysexcludes by local weekday.start/endareHH:MM;endis exclusive at minute granularity, so09:00–17:00means[09:00, 17:00)and16:59is inside it but17:00is not.end: "24:00"is accepted to mean “through midnight” (the only way to include the last minute of the day under an exclusive end).start > endis a wrap-around window (e.g.22:00–06:00) and matches when the local time is at/afterstartOR beforeend. Atime_windowwith notzof its own is valid only when the constraints file sets a top-leveldefault_tz:(a store-level setting, not a constraint field — adding or changing it never changes any constraint’sprovenance_hash); otherwise it is quarantined at load asinvalid: time_window.tz missing and no default_tz. The evaluation instant (now) must be timezone-aware everywhere in the matcher; a naivedatetimeraisesValueErrorrather than silently assuming UTC.
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.