Edictum
Guides

Python Rules

Define enforcement in Python when the built-in YAML operators are not enough.

AI Assistance

Right page if: you need to write Python rules with @precondition, @postcondition, or @session_contract, or you need Decision / ToolCall examples. Wrong page if: your logic fits declarative YAML -- see https://docs.edictum.ai/docs/guides/writing-rules. Gotcha: Python rules use `Decision` and `ToolCall`. The public API does not currently offer `from_yaml(..., rules=[...])` -- Python rules are passed through `Edictum(rules=[...])`.

Define enforcement in Python when you need logic that YAML operators cannot express cleanly. Python rules run in the same pipeline as YAML rules, but the public API paths are different:

  • Use Edictum.from_yaml(...) for YAML rulesets
  • Use Edictum(rules=[...]) for Python rules

Decision

Every Python rule returns a Decision:

from edictum import Decision

Decision.pass_()
Decision.fail("Destructive command blocked: rm -rf /")
Decision.fail("Trade exceeds limit", trade_id="T-1234", amount=5000)
MethodReturnsDescription
Decision.pass_()Decision(passed=True)Rule passed
Decision.fail(message, **metadata)Decision(passed=False, message=..., metadata=...)Rule failed. Message is truncated to 500 chars.

Messages are returned to the agent. Make them specific enough for self-correction.

Preconditions

Preconditions run before tool execution. If one fails, the call is blocked and the tool never runs.

from edictum import Decision, Edictum, ToolCall, precondition

@precondition(tool="bash")
def no_destructive_commands(tool_call: ToolCall) -> Decision:
    cmd = tool_call.args.get("command", "")
    destructive = {"rm", "rmdir", "shred", "mkfs"}
    first_token = cmd.split()[0] if cmd.split() else ""
    if first_token in destructive:
        return Decision.fail(f"Destructive command blocked: {cmd}")
    return Decision.pass_()

guard = Edictum(rules=[no_destructive_commands])

Decorator signature

@precondition(tool="tool_name", when=optional_filter)
def my_rule(tool_call: ToolCall) -> Decision:
    ...
ParameterTypeDescription
toolstrTool name to match. Use "*" for all tools.
whenCallable | NoneOptional filter: when(tool_call) -> bool. Rule only runs if True.

ToolCall

Python rules receive a ToolCall, the immutable snapshot of the current invocation:

FieldTypeDescription
tool_namestrName of the tool being called
argsdictDeep-copied tool arguments
principalPrincipal | NoneIdentity context
environmentstrDeployment environment
side_effectSideEffectPURE, READ, WRITE, or IRREVERSIBLE
bash_commandstr | NoneExtracted bash command for Bash
file_pathstr | NoneExtracted file path for file tools
metadatadictArbitrary per-call metadata
call_idstrUnique UUID for this call
call_indexintSequential position in the session
timestampdatetimeUTC timestamp

Async preconditions

@precondition(tool="deploy_service")
async def check_change_freeze(tool_call: ToolCall) -> Decision:
    is_frozen = await ops_api.is_change_freeze(tool_call.environment)
    if is_frozen:
        return Decision.fail("Change freeze active. No deploys until lifted.")
    return Decision.pass_()

Conditional execution with when

@precondition(tool="*", when=lambda tool_call: tool_call.environment == "production")
def prod_only_check(tool_call: ToolCall) -> Decision:
    principal = tool_call.principal
    if not principal or not principal.ticket_ref:
        return Decision.fail("Production changes require a ticket reference.")
    return Decision.pass_()

Postconditions

Postconditions run after tool execution and inspect the tool output.

import re
from edictum import Decision, Edictum, ToolCall, postcondition

@postcondition(tool="*")
def check_pii_in_output(tool_call: ToolCall, tool_response: object) -> Decision:
    text = str(tool_response)
    if re.search(r"\b\d{3}-\d{2}-\d{4}\b", text):
        return Decision.fail("PII detected in tool output.")
    return Decision.pass_()

guard = Edictum(rules=[check_pii_in_output])

Decorator signature

@postcondition(tool="tool_name", when=optional_filter)
def my_rule(tool_call: ToolCall, tool_response: object) -> Decision:
    ...

Python decorator postconditions are warn-oriented today:

  • failure on READ / PURE tools produces warning or retry context
  • failure on WRITE / IRREVERSIBLE tools falls back to warn because the side effect already happened
  • YAML rulesets are the public path for action: redact and action: block

Session Rules

Session rules enforce cross-turn limits using persisted counters. They must be async because Session methods are async.

from edictum import Decision, Edictum, Session, session_contract

@session_contract
async def limit_operations(session: Session) -> Decision:
    count = await session.execution_count()
    if count >= 50:
        return Decision.fail("Session limit reached (50 tool calls). Summarize and stop.")
    return Decision.pass_()

guard = Edictum(rules=[limit_operations])

Session methods

MethodReturnsDescription
await session.execution_count()intTotal tool executions
await session.attempt_count()intTotal attempts, including blocked calls
await session.tool_execution_count(tool)intExecutions for one tool
await session.consecutive_failures()intConsecutive failed executions
session.session_idstrSession identifier

Built-in: deny_sensitive_reads()

The built-in helper is still named deny_sensitive_reads(), but its behavior is straightforward: it blocks reads of common secret paths and env dumps.

from edictum import Edictum, deny_sensitive_reads

guard = Edictum(rules=[deny_sensitive_reads()])

guard = Edictum(
    rules=[
        deny_sensitive_reads(
            sensitive_paths=["/.private/", "/secrets/", "/.env"],
            sensitive_commands=["printenv", "env", "set"],
        )
    ]
)

Default sensitive path matches include /.ssh/, /var/run/secrets/, /.env, /.aws/credentials, /.git-credentials, /id_rsa, and /id_ed25519.

Factory Pattern

from edictum import Decision, Edictum, ToolCall, precondition

def make_require_target_dir(allowed_base: str):
    @precondition(tool="bash")
    def require_target_dir(tool_call: ToolCall) -> Decision:
        cmd = tool_call.args.get("command", "")
        tokens = cmd.split()
        if tokens and tokens[0] in ("mv", "cp") and len(tokens) >= 3:
            target = tokens[-1]
            if not target.startswith(allowed_base):
                return Decision.fail(
                    f"Target '{target}' is outside {allowed_base}. "
                    f"Move files to {allowed_base} instead."
                )
        return Decision.pass_()

    return require_target_dir

guard = Edictum(rules=[make_require_target_dir("/tmp/organized/")])

Python vs YAML

These are the supported public paths today:

from edictum import Edictum

# YAML rulesets
yaml_guard = Edictum.from_yaml("rules.yaml")

# Python rules
python_guard = Edictum(rules=[no_destructive_commands, check_pii_in_output])

There is currently no public Edictum.from_yaml("rules.yaml", rules=[...]) merge path. If you need one guard that mixes YAML and Python rules, that still needs a first-class public API.

Low-Level API

BashClassifier

from edictum import BashClassifier, SideEffect

BashClassifier.classify("ls -la")               # SideEffect.READ
BashClassifier.classify("git status")           # SideEffect.READ
BashClassifier.classify("rm -rf /tmp")          # SideEffect.IRREVERSIBLE
BashClassifier.classify("cat foo | grep x")     # SideEffect.IRREVERSIBLE
BashClassifier.classify("echo $AWS_SECRET_KEY") # SideEffect.IRREVERSIBLE

create_envelope()

from edictum import Principal, create_envelope

tool_call = create_envelope(
    tool_name="read_file",
    tool_input={"path": "/etc/passwd"},
    run_id="session-123",
    call_index=0,
    principal=Principal(user_id="alice", role="analyst"),
    environment="production",
)

create_envelope() deep-copies args and metadata, validates tool_name, and applies side-effect classification. Direct ToolCall(...) construction is allowed, but it skips the deep-copy guarantees.

Testing Python Rules

Use guard.evaluate() for dry-run testing without executing the tool:

from edictum import Edictum, Principal

def test_destructive_blocked():
    guard = Edictum(rules=[no_destructive_commands])
    result = guard.evaluate("bash", {"command": "rm -rf /"})
    assert result.decision == "block"
    assert "Destructive command blocked" in result.block_reasons[0]

def test_safe_command_allowed():
    guard = Edictum(rules=[no_destructive_commands])
    result = guard.evaluate("bash", {"command": "ls -la"})
    assert result.decision == "allow"

def test_role_gated():
    guard = Edictum(rules=[prod_only_check])
    result = guard.evaluate(
        "deploy_service",
        {"env": "production"},
        principal=Principal(role="analyst"),
        environment="production",
    )
    assert result.decision == "block"

For end-to-end tests with real tool execution, use guard.run() and assert on EdictumDenied when a call is blocked.

Reference Implementation

The edictum-demo DevOps scenario contains a complete governed example with Python rules, including deny_sensitive_reads(), custom preconditions, and factory patterns.

Last updated on

On this page