Edictum
Guides

Custom Operators

Edictum ships with 15 built-in operators (`contains`, `matches`, `gt`, etc.).

AI Assistance

Right page if: you need domain-specific validation in YAML contracts that the 15 built-in operators cannot express (IBAN, CIDR, semver, etc.). Wrong page if: you need complex logic with branching or external API calls -- see https://docs.edictum.ai/docs/guides/python-hooks. For the built-in operator list, see https://docs.edictum.ai/docs/contracts/operators. Gotcha: custom operator names that clash with built-in operators (e.g., "contains") raise EdictumConfigError. Unknown operators in YAML are caught at load time, not at evaluation time -- typos fail fast.

Edictum ships with 15 built-in operators (contains, matches, gt, etc.). Custom operators let you extend the expression grammar with domain-specific checks — IBAN validation, CIDR matching, semver comparison — so contracts stay declarative YAML instead of falling back to Python hooks.

import ipaddress

def ip_in_cidr(field_value: str, cidr: str) -> bool:
    """Check if an IP address is within a CIDR range."""
    return ipaddress.ip_address(field_value) in ipaddress.ip_network(cidr)

guard = Edictum.from_yaml(
    "contracts.yaml",
    custom_operators={"ip_in_cidr": ip_in_cidr},
)
# contracts.yaml
- id: internal-only
  type: pre
  tool: ssh_connect
  when:
    args.target_ip: { ip_in_cidr: "10.0.0.0/8" }
  then:
    effect: deny
    message: "Only internal IPs allowed. Got {args.target_ip}."

Operator contract

Every custom operator is a callable with this signature:

def my_operator(field_value: Any, operator_value: Any) -> bool:
    ...
ParameterDescription
field_valueThe resolved value from the selector (e.g., the value of args.target_ip).
operator_valueThe value from the YAML operator (e.g., "10.0.0.0/8").
ReturnTrue if the condition is met (contract fires), False otherwise.

The return value is coerced to bool. Truthy non-bool values (like 1 or "yes") work but True/False is preferred.

Error handling

  • If the operator raises TypeError, Edictum treats it as a policy_error (fail-closed — the contract fires).
  • If the operator raises any other exception, Edictum treats it the same way (fail-closed).
  • Missing fields are never passed to custom operators. When a selector resolves to a missing or null field, the expression evaluates to False without calling the operator.

Registering operators

Pass custom_operators to any of the YAML loading methods:

guard = Edictum.from_yaml(
    "contracts.yaml",
    custom_operators={"ip_in_cidr": ip_in_cidr},
)
guard = Edictum.from_yaml_string(
    yaml_content,
    custom_operators={"ip_in_cidr": ip_in_cidr},
)
guard = Edictum.from_template(
    "file-agent",
    custom_operators={"ip_in_cidr": ip_in_cidr},
)

Multiple operators can be registered at once:

guard = Edictum.from_yaml(
    "contracts.yaml",
    custom_operators={
        "ip_in_cidr": ip_in_cidr,
        "is_invalid_iban": is_invalid_iban,
        "semver_lt": semver_lt,
    },
)

Name clash protection

Custom operator names must not collide with the 15 built-in operators. Attempting to register a name like contains or equals raises EdictumConfigError:

# This raises EdictumConfigError
guard = Edictum.from_yaml_string(
    yaml_content,
    custom_operators={"contains": my_fn},  # clash!
)
EdictumConfigError: Custom operator names clash with built-in operators: ['contains']

Unknown operator detection

If a YAML contract uses an operator name that is neither built-in nor registered as a custom operator, Edictum raises EdictumConfigError at compile time (when loading the bundle), not at evaluation time:

# contracts.yaml uses `ip_in_cidr` but no custom_operators registered
guard = Edictum.from_yaml_string(yaml_content)
# EdictumConfigError: Contract 'internal-only': unknown operator 'ip_in_cidr'

This means typos and missing registrations are caught immediately, not when a tool call happens to trigger the contract.


Composing with boolean expressions

Custom operators compose with all:, any:, and not: the same way built-in operators do:

- id: internal-approved-only
  type: pre
  tool: ssh_connect
  when:
    all:
      - args.target_ip: { ip_in_cidr: "10.0.0.0/8" }
      - principal.role: { not_in: [sre, admin] }
  then:
    effect: deny
    message: "Internal access requires SRE or admin role."

Custom and built-in operators can be mixed freely within the same expression tree.


Dry-run evaluation

Custom operators work with the dry-run evaluation API:

result = guard.evaluate(
    "ssh_connect",
    {"target_ip": "192.168.1.1"},
)
print(result.verdict)  # "deny" or "allow"

Examples

IBAN validation

import re

IBAN_RE = re.compile(r'^[A-Z]{2}\d{2}[A-Z0-9]{4}\d{7}([A-Z0-9]?){0,16}$')

def is_invalid_iban(field_value: str, expected: bool) -> bool:
    """Return True when the IBAN is invalid (deny condition)."""
    valid = bool(IBAN_RE.match(str(field_value)))
    return (not valid) == expected
- id: validate-iban
  type: pre
  tool: wire_transfer
  when:
    args.destination_account: { is_invalid_iban: true }
  then:
    effect: deny
    message: "Invalid IBAN: {args.destination_account}"

CIDR range check

import ipaddress

def ip_in_cidr(field_value: str, cidr: str) -> bool:
    """Return True when the IP is within the CIDR range."""
    try:
        return ipaddress.ip_address(field_value) in ipaddress.ip_network(cidr)
    except ValueError:
        return False
# Deny connections to internal IPs
- id: block-internal
  type: pre
  tool: ssh_connect
  when:
    args.target_ip: { ip_in_cidr: "10.0.0.0/8" }
  then:
    effect: deny
    message: "Internal IP {args.target_ip} denied."

Semver comparison

from packaging.version import Version

def semver_lt(field_value: str, threshold: str) -> bool:
    """Return True when field version is below threshold."""
    return Version(field_value) < Version(threshold)
# Deny deploying packages below minimum version
- id: min-version
  type: pre
  tool: deploy_package
  when:
    args.version: { semver_lt: "2.0.0" }
  then:
    effect: deny
    message: "Version {args.version} is below the minimum 2.0.0."

Next steps

Last updated on

On this page