Language reference
The complete CrowdControl grammar, operators, and semantics. For the normative spec (used by SDK ports), see SPEC.md.
Rule kinds
CrowdControl has three rule kinds. Every rule is a block with a name and a body.
forbid "name" { ... }— denies when all conditions match, unless anunlessclause saves it.warn "name" { ... }— same semantics as forbid, but tooling should treat it as non-blocking.permit "name" { ... }— explicit allow. Emits a message but does not override a sibling forbid at the engine level (that's a higher-level adapter concern).
Rule body
Inside a rule body, you can have:
- Optional metadata:
description,owner,link - One or more conditions (all AND'd together)
- Zero or more
unlessclauses (OR'd — any one true saves the rule) - An optional
messagewith{field.path}interpolation
forbid "example" {
description "Human-readable explanation"
owner "team-name"
link "https://runbook.example.com/..."
# conditions (all AND'd)
user.role == "intern"
resource.environment == "production"
# escape clauses (OR'd — any one saves the rule)
unless user.groups contains "platform-oncall"
unless has labels.emergency-approved
message "{user.name} cannot touch {resource.name} in prod"
}
Conditions
Conditions reference fields of the input document using dotted paths.
Comparisons
user.role == "admin"
pr.approvals != 0
pr.approvals >= 2
count(plan.destroys) < 10
List membership
resource.type in ["aws_iam_role", "aws_kms_key"]
author.teams contains "security"
pr.changed_files contains "secrets.tf"
List operations
author.teams intersects ["security", "platform"]
labels is_subset ["approved", "reviewed", "urgent"]
Patterns and regex
resource.type matches "aws_iam_*"
resource.name matches_regex "^[a-z][a-z0-9_-]+$"
Field existence
has resource.change.after.acl
not has config.required_field
Quantifiers
any pr.changed_files matches "infra/*"
all pr.commit_authors in ["alice", "bob", "charlie"]
Empty list: any → false, all → true (vacuous).
Aggregates
count(plan.destroys) > 5
count(plan.creates) + count(plan.destroys) > 20
Transforms
lower(author.name) == "admin"
upper(resource.provider) == "AWS"
len(pr.title) >= 10
len(labels) >= 2
Arithmetic
count(plan.destroys) * 3 + count(plan.creates) > 20
pr.approvals * 2 >= count(plan.destroys)
Operators: +, -, *, /. Follows mathematical precedence. Division by zero evaluates to false (never panics).
Boolean operators
# not — negates a single condition
not resource.type matches "aws_*"
# or — disjunction within a single line
resource.type == "aws_iam_role" or resource.type == "aws_kms_key"
Multiple condition lines are always AND'd. or only binds within a single line.
Unless clauses
unless is CrowdControl's escape hatch. If any unless clause is true, the rule does not fire — regardless of whether the main conditions matched.
forbid "sensitive-iam" {
resource.type matches "aws_iam_*"
unless author.teams contains "security"
unless labels contains "security-approved"
unless pr.approvals >= 2
message "IAM changes need security approval"
}
Any one of the three unless clauses being true saves the rule. Think of it as "forbid this… unless any of these exceptions apply".
Messages and interpolation
message strings support {field.path} placeholders that resolve at result time. You can also use {count(field.path)}.
message "{user.name} tried to delete {resource.address}"
message "too many destroys ({count(plan.destroys)}) — needs platform-team"
Unresolved placeholders are left literally, which makes missing fields easy to spot.
Default effects
The engine supports two modes:
- DefaultAllow (default): documents pass unless a
forbidfires. - DefaultDeny: documents are denied unless a
permitfires.
In default-deny mode, if no permit fires and nothing already denied the document, an implicit (default-deny) result is added.
Comments
# line comment
// also a line comment
There are no block comments.