Custom policy DSL¶
Ampora's custom policies and tenant-specific lint rules use a small expression language designed to be readable, fail-closed, and non-Turing-complete.
Why a small DSL¶
- Auditable: you can read a one-liner. You cannot easily read 200 lines of imperative Python.
- Sandboxed: no IO, no recursion, no mutation. The interpreter cannot reach the network or disk.
- Bounded: the recursive-descent parser caps nesting depth and the evaluator has a 50 ms wall-clock budget. A pathological expression cannot DoS the rollout engine.
Grammar (informal)¶
expr := or_expr
or_expr := and_expr ('or' and_expr)*
and_expr := not_expr ('and' not_expr)*
not_expr := 'not' not_expr | comparison
comparison := value op value
| value 'in' '(' value (',' value)* ')'
| value 'matches' regex
| value 'has-key' identifier
op := '=' | '!=' | '<' | '<=' | '>' | '>='
value := literal | path | function-call
literal := number | string | boolean | null
path := identifier ('.' identifier | '[' literal ']')*
function-call := identifier '(' value (',' value)* ')'
Variables you can reference¶
Inside a policy body, the available context is:
| Reference | Meaning |
|---|---|
agent.description | The agent's agent_description JSONB |
agent.capabilities | List of capability strings |
agent.group | The current group's name |
config.yaml | The Configuration's parsed AST as a tree |
config.exporters | Convenience: list of (alias, type, props) tuples |
config.processors | Same shape for processors |
config.receivers | Same shape for receivers |
config.pipelines | Map: signal → list of (receivers, processors, exporters) |
tenant | Current tenant name |
Inside a lint rule body, only config.* is available — lint runs without an agent context.
Functions¶
A small set of safe functions:
| Function | What it does |
|---|---|
len(x) | Length of a string or list |
lower(s), upper(s) | Case conversion |
starts-with(s, prefix), ends-with(s, suffix) | String prefix/suffix tests |
regex(pattern) | A compiled regex; use with matches |
count(list, condition) | Count items in a list matching a sub-expression |
any(list, condition), all(list, condition) | Aggregates |
Examples¶
"No exporter to a non-EU endpoint"¶
all (e in config.exporters where e.type = "otlp")
where ends-with(e.props.endpoint, ".eu.example.com")
Reads as: "for every otlp exporter, the endpoint must end in .eu.example.com." A counter-example anywhere returns false, the policy denies.
"Hostmetrics interval ≥ 60s"¶
"No debug exporter in production"¶
agent.description.environment = "prod"
and any (e in config.exporters where e.type = "debug")
=> deny
Note the => deny — by default the engine reads the expression as "this must hold or deny"; the explicit => deny form is for the "must NOT hold" case.
"The agent must be tagged with service.name"¶
"Pipelines must include batch"¶
Evaluation guarantees¶
- Order: expressions are evaluated left-to-right, short-circuit on
and/or. - Type: arithmetic on mismatched types returns the unit value and the expression evaluates to
false. - Errors: a regex compilation error, a missing key in a strict context, etc. → policy denies (fail-closed).
- Time: 50 ms hard ceiling on a single evaluation. Beyond → deny.
Testing your expression¶
The policy editor has an Evaluate panel where you can paste a sample agent + config JSON and see the result. Drafts also run in shadow mode against the live system; the audit log surfaces every "would-deny" so you can dry-run before activating.
Versioning¶
Policies are versioned. Saving a draft creates a new version; the approver sees a diff between the previous Active version and the new draft. Older versions remain in the audit history.
Beyond the DSL¶
If your governance need genuinely cannot be expressed in the DSL — e.g. it requires reaching out to an external service — file a feature request. We will consider extending the function set or, for very specific cases, adding a typed extension point. We do not intend to make the DSL Turing-complete.