Python Rules
Define enforcement in Python when the built-in YAML operators are not enough.
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)| Method | Returns | Description |
|---|---|---|
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:
...| Parameter | Type | Description |
|---|---|---|
tool | str | Tool name to match. Use "*" for all tools. |
when | Callable | None | Optional filter: when(tool_call) -> bool. Rule only runs if True. |
ToolCall
Python rules receive a ToolCall, the immutable snapshot of the current invocation:
| Field | Type | Description |
|---|---|---|
tool_name | str | Name of the tool being called |
args | dict | Deep-copied tool arguments |
principal | Principal | None | Identity context |
environment | str | Deployment environment |
side_effect | SideEffect | PURE, READ, WRITE, or IRREVERSIBLE |
bash_command | str | None | Extracted bash command for Bash |
file_path | str | None | Extracted file path for file tools |
metadata | dict | Arbitrary per-call metadata |
call_id | str | Unique UUID for this call |
call_index | int | Sequential position in the session |
timestamp | datetime | UTC 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/PUREtools produces warning or retry context - failure on
WRITE/IRREVERSIBLEtools falls back to warn because the side effect already happened - YAML rulesets are the public path for
action: redactandaction: 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
| Method | Returns | Description |
|---|---|---|
await session.execution_count() | int | Total tool executions |
await session.attempt_count() | int | Total attempts, including blocked calls |
await session.tool_execution_count(tool) | int | Executions for one tool |
await session.consecutive_failures() | int | Consecutive failed executions |
session.session_id | str | Session 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.IRREVERSIBLEcreate_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