Edictum
Guides

Python Hooks

Some enforcement logic doesn't fit in YAML contracts.

AI Assistance

Right page if: you need enforcement logic that requires Python -- external API calls, dynamic allowlists, ML classification, or custom side effects alongside YAML contracts. Wrong page if: your logic can be expressed declaratively -- see https://docs.edictum.ai/docs/guides/writing-contracts for YAML contracts. For reusable domain operators in YAML, see https://docs.edictum.ai/docs/guides/custom-operators. Gotcha: hooks are not available via Edictum.from_yaml() -- they require programmatic setup through the Edictum constructor. Before hooks run BEFORE preconditions and can deny; after hooks run AFTER postconditions and are side-effect only.

Some enforcement logic doesn't fit in YAML contracts. You might need to call an external service, check a dynamic allowlist, or log tool calls to a custom system. Python hooks let you run arbitrary code before or after tool execution, alongside your YAML contracts.


Quick Example

from edictum import Edictum, HookRegistration, HookDecision

def block_destructive(envelope):
    """Deny any bash command containing 'rm -rf'."""
    cmd = envelope.args.get("command", "")
    if "rm -rf" in cmd:
        return HookDecision.deny("Destructive command denied")
    return HookDecision.allow()

guard = Edictum(
    hooks=[
        HookRegistration(phase="before", tool="bash", callback=block_destructive),
    ],
)

The hook runs before every bash tool call. If the command contains rm -rf, the call is denied and the tool never executes.


Core Types

HookResult

An enum with two values:

ValueMeaning
HookResult.ALLOWThe hook permits the tool call
HookResult.DENYThe hook denies the tool call

HookDecision

A dataclass returned by before hooks to signal the pipeline's next step.

FieldTypeDescription
resultHookResultWhether to allow or deny
reasonstr | NoneDenial reason (truncated to 500 characters)

Two class methods for convenience:

HookDecision.allow()               # allow the call
HookDecision.deny("reason text")   # deny with a reason

HookRegistration

A dataclass that binds a callback to a pipeline phase and tool.

FieldTypeDescription
phasestr"before" or "after"
toolstrTool name to match, or "*" for all tools
callbackcallableThe hook function
whencallable | NoneOptional filter: when(envelope) -> bool

Before Hooks

Before hooks run before preconditions in the pipeline. They receive a ToolEnvelope and must return a HookDecision.

from edictum import HookRegistration, HookDecision

def check_allowlist(envelope):
    allowed_tools = {"read_file", "list_dir", "search"}
    if envelope.tool_name not in allowed_tools:
        return HookDecision.deny(f"Tool '{envelope.tool_name}' is not in the allowlist")
    return HookDecision.allow()

hook = HookRegistration(phase="before", tool="*", callback=check_allowlist)

If a before hook returns HookDecision.deny(...), the tool call is denied immediately. Preconditions and session contracts are not evaluated.


After Hooks

After hooks run after postconditions in the pipeline. They receive a ToolEnvelope and the tool's response. The return value is ignored -- after hooks are for side effects like logging or metrics.

from edictum import HookRegistration

def log_tool_result(envelope, response):
    print(f"[audit] {envelope.tool_name} returned {len(str(response))} chars")

hook = HookRegistration(phase="after", tool="*", callback=log_tool_result)

After hooks cannot deny tool calls. The tool has already executed by the time they run.


Tool Targeting

Set tool to a specific tool name to match only that tool, or "*" to match all tools:

# Only fires for "deploy_service"
HookRegistration(phase="before", tool="deploy_service", callback=my_hook)

# Fires for every tool call
HookRegistration(phase="before", tool="*", callback=my_hook)

Conditional Hooks

The when parameter accepts a callable that receives the ToolEnvelope and returns a bool. The hook only fires when when returns True:

def is_production(envelope):
    return envelope.environment == "production"

hook = HookRegistration(
    phase="before",
    tool="deploy_service",
    callback=require_approval,
    when=is_production,
)

This hook only runs for deploy_service calls in the production environment.


Async Support

Hook callbacks can be sync or async. The pipeline detects coroutines and awaits them automatically:

import httpx
from edictum import HookRegistration, HookDecision

async def check_external_policy(envelope):
    async with httpx.AsyncClient() as client:
        resp = await client.post(
            "https://policy.internal/check",
            json={"tool": envelope.tool_name, "args": envelope.args},
        )
        if resp.json().get("denied"):
            return HookDecision.deny(resp.json()["reason"])
    return HookDecision.allow()

hook = HookRegistration(phase="before", tool="*", callback=check_external_policy)

Error Handling

If a before hook raises an exception, the pipeline treats it as a denial:

# If this hook raises, the tool call is denied with:
# "Hook error: <exception message>"
def risky_hook(envelope):
    raise RuntimeError("service unavailable")
    # Pipeline denies with: "Hook error: service unavailable"

If an after hook raises an exception, the error is logged but does not affect the tool result. The tool has already executed -- the pipeline does not propagate after-hook errors.


Pipeline Order

Hooks fit into the pipeline at specific positions:

  1. Attempt limit check
  2. Before hooks (can deny)
  3. Preconditions (can deny)
  4. Sandbox contracts (can deny)
  5. Session contracts (can deny)
  6. Execution limits check
  7. Tool executes
  8. Postconditions (warn/redact/deny for READ/PURE tools)
  9. After hooks (side effects only)
  10. Audit event emitted

Before hooks run first -- a denial from a hook skips all subsequent checks. This makes hooks useful for fast-path rejections that don't need contract evaluation.


Registering Hooks

Pass hooks to the Edictum constructor via the hooks parameter:

from edictum import Edictum, HookRegistration, HookDecision

def audit_hook(envelope):
    print(f"Tool call: {envelope.tool_name}")
    return HookDecision.allow()

def log_result(envelope, response):
    print(f"Result: {response}")

guard = Edictum(
    hooks=[
        HookRegistration(phase="before", tool="*", callback=audit_hook),
        HookRegistration(phase="after", tool="*", callback=log_result),
    ],
    contracts=[...],
)

Hooks can be combined with YAML contracts. Load contracts from YAML separately and pass hooks alongside:

from edictum import Edictum, HookRegistration, HookDecision
from edictum.yaml_engine.loader import load_bundle
from edictum.yaml_engine.compiler import compile_contracts

# Load YAML contracts
bundle_data, bundle_hash = load_bundle("contracts.yaml")
compiled = compile_contracts(bundle_data)

guard = Edictum(
    contracts=compiled.preconditions + compiled.postconditions + compiled.session_contracts,
    limits=compiled.limits,
    hooks=[
        HookRegistration(phase="before", tool="*", callback=my_hook),
    ],
)

Python-only

Hooks are not available via Edictum.from_yaml(). They require programmatic setup through the Edictum constructor.


Next Steps

Last updated on

On this page