Skip to content

Quickstart

Installation

pip install edictum            # core only
pip install edictum[all]       # all 6 framework adapters + OTel
pip install edictum[langchain] # individual adapter extras

Requires Python 3.11+. Zero runtime dependencies for the core package.

Framework-Agnostic Usage (guard.run)

guard.run() is the simplest way to govern a tool call. It runs the full governance pipeline, executes the tool if allowed, and raises EdictumDenied if blocked.

import asyncio
from edictum import (
    Edictum,
    EdictumDenied,
    EdictumToolError,
    Verdict,
    deny_sensitive_reads,
    precondition,
)


# A custom precondition: block Bash commands starting with "rm"
@precondition("Bash")
def no_destructive_commands(envelope):
    if envelope.bash_command and envelope.bash_command.strip().startswith("rm"):
        return Verdict.fail(
            "Destructive command blocked. Use a safer alternative or "
            "request explicit approval before deleting files."
        )
    return Verdict.pass_()


guard = Edictum(
    contracts=[
        deny_sensitive_reads(),
        no_destructive_commands,
    ],
)


async def run_bash(command):
    """Mock tool — in production this would execute a shell command."""
    return f"executed: {command}"


async def main():
    # Allowed: normal command
    result = await guard.run("Bash", {"command": "ls -la"}, run_bash)
    print(result)  # "executed: ls -la"

    # Denied: destructive command
    try:
        await guard.run("Bash", {"command": "rm -rf /tmp/data"}, run_bash)
    except EdictumDenied as e:
        print(f"Denied: {e.reason}")
        print(f"Source: {e.decision_source}")  # "precondition"

    # Denied: sensitive path
    try:
        await guard.run(
            "Read",
            {"file_path": "/home/user/.ssh/id_rsa"},
            lambda file_path: open(file_path).read(),
        )
    except EdictumDenied as e:
        print(f"Denied: {e.reason}")


asyncio.run(main())

Observe Mode

Observe mode runs the full pipeline but never blocks. Denials are logged as CALL_WOULD_DENY instead of CALL_DENIED. Use this for shadow deployment -- see what would break before enforcing rules.

import asyncio
from edictum import Edictum, Verdict, deny_sensitive_reads, precondition
from edictum.audit import FileAuditSink


guard = Edictum(
    mode="observe",
    contracts=[deny_sensitive_reads()],
    audit_sink=FileAuditSink("audit.jsonl"),
)


async def run_bash(command):
    return f"executed: {command}"


async def main():
    # This would be denied in enforce mode, but observe mode allows it through.
    # The audit log records CALL_WOULD_DENY so you can review violations.
    result = await guard.run("Bash", {"command": "cat ~/.ssh/id_rsa"}, run_bash)
    print(result)  # "executed: cat ~/.ssh/id_rsa"


asyncio.run(main())

Framework Adapters

Edictum ships thin adapters for 6 agent frameworks. Each translates between the framework's hook interface and the shared governance pipeline.

from edictum import Edictum, deny_sensitive_reads, OperationLimits

guard = Edictum(
    contracts=[deny_sensitive_reads()],
    limits=OperationLimits(max_tool_calls=100),
)

LangChain:

from edictum.adapters.langchain import LangChainAdapter
adapter = LangChainAdapter(guard, session_id="session-lc")
# pre_result = await adapter._pre_tool_call(request)
# await adapter._post_tool_call(request, result)

CrewAI:

from edictum.adapters.crewai import CrewAIAdapter
adapter = CrewAIAdapter(guard, session_id="session-crew")
# allowed = await adapter._before_hook(context)  # False = denied
# await adapter._after_hook(context)

Agno:

from edictum.adapters.agno import AgnoAdapter
adapter = AgnoAdapter(guard, session_id="session-agno")
# result = await adapter._hook_async(name, callable, args)

Semantic Kernel:

from edictum.adapters.semantic_kernel import SemanticKernelAdapter
adapter = SemanticKernelAdapter(guard, session_id="session-sk")
# pre = await adapter._pre(name, args, call_id)  # {} or "DENIED: ..."
# await adapter._post(call_id, result)

OpenAI Agents SDK:

from edictum.adapters.openai_agents import OpenAIAgentsAdapter
adapter = OpenAIAgentsAdapter(guard, session_id="session-oai")
# pre = await adapter._pre(name, args, call_id)  # None or "DENIED: ..."
# await adapter._post(call_id, result)

Claude Agent SDK:

from edictum.adapters.claude_agent_sdk import ClaudeAgentSDKAdapter
adapter = ClaudeAgentSDKAdapter(guard, session_id="session-claude")
# pre = await adapter._pre_tool_use(name, input, id)  # {} or deny dict
# await adapter._post_tool_use(id, response)

Each adapter manages pending state between pre and post hooks, tracks call indices, and emits audit events. See examples/ for live demos of all 6 adapters.

Writing Contracts

Preconditions

Preconditions run before execution. They receive a ToolEnvelope and return a Verdict. If the verdict fails, the tool call is denied and the agent receives the failure message.

from edictum import Verdict, precondition


# Target a specific tool
@precondition("Bash")
def require_safe_prefix(envelope):
    allowed = ["ls", "cat", "git status", "git diff", "pwd"]
    cmd = (envelope.bash_command or "").strip()
    if not any(cmd == p or cmd.startswith(p + " ") for p in allowed):
        return Verdict.fail(
            f"Command '{cmd}' is not in the safe prefix list. "
            "Use one of: ls, cat, git status, git diff, pwd."
        )
    return Verdict.pass_()


# Target all tools with a wildcard
@precondition("*")
def block_production(envelope):
    if envelope.environment == "production":
        return Verdict.fail("Tool calls are disabled in production.")
    return Verdict.pass_()

Postconditions

Postconditions run after execution. They are observe-only -- they emit warnings but never block. The warning message adapts to the tool's side-effect classification:

  • Pure/Read tools: warning suggests retrying.
  • Write/Irreversible tools: warning says "assess before proceeding" (no retry coaching for something that already mutated state).
from edictum import Verdict
from edictum.contracts import postcondition


@postcondition("Bash")
def check_exit_status(envelope, result):
    if isinstance(result, str) and "Error:" in result:
        return Verdict.fail(
            f"Bash command returned an error: {result[:200]}"
        )
    return Verdict.pass_()

Session Contracts

Session contracts check cross-turn state using persisted atomic counters. They must be async because session methods are async.

from edictum import Verdict
from edictum.contracts import session_contract


@session_contract
async def limit_bash_calls(session):
    bash_count = await session.tool_execution_count("Bash")
    if bash_count >= 50:
        return Verdict.fail(
            "Bash execution limit reached (50). Summarize progress "
            "and use non-Bash tools to continue."
        )
    return Verdict.pass_()

Writing Hooks

Hooks are lower-level than contracts. A before-hook receives the ToolEnvelope and returns a HookDecision. Hooks run before preconditions in the pipeline.

from edictum import Edictum
from edictum.hooks import HookDecision
from edictum.types import HookRegistration


def log_and_allow(envelope):
    print(f"[hook] tool={envelope.tool_name} args={envelope.args}")
    return HookDecision.allow()


def deny_after_hours(envelope):
    from datetime import datetime, timezone
    hour = datetime.now(timezone.utc).hour
    if hour < 6 or hour > 22:
        return HookDecision.deny("Tool calls blocked outside business hours (06-22 UTC).")
    return HookDecision.allow()


guard = Edictum(
    hooks=[
        HookRegistration(phase="before", tool="*", callback=log_and_allow),
        HookRegistration(phase="before", tool="Bash", callback=deny_after_hours),
    ],
)

After-hooks observe the result. They receive (envelope, result) and don't return a decision.

def audit_after(envelope, result):
    print(f"[after] {envelope.tool_name} completed")


guard = Edictum(
    hooks=[
        HookRegistration(phase="after", tool="*", callback=audit_after),
    ],
)

Audit & Redaction

Every tool call emits a structured AuditEvent to a configurable sink. Two built-in sinks are provided:

from edictum import Edictum
from edictum.audit import FileAuditSink, RedactionPolicy, StdoutAuditSink

# JSON to stdout (default)
guard = Edictum(audit_sink=StdoutAuditSink())

# JSON lines to a file
guard = Edictum(audit_sink=FileAuditSink("audit.jsonl"))

Redaction

RedactionPolicy strips sensitive data at write time. Redaction is destructive -- there is no recovery path.

What gets auto-redacted:

  • Sensitive keys: any dict key containing token, key, secret, password, or credential (plus a full default set).
  • Secret value patterns: OpenAI keys (sk-...), AWS access keys (AKIA...), JWTs (eyJ...), GitHub tokens (ghp_...), Slack tokens (xox...).
  • Bash credentials: export SECRET_KEY=..., -p password, URL credentials (://user:pass@).
  • Payload cap: audit events exceeding 32KB are truncated.

Custom configuration:

from edictum import Edictum
from edictum.audit import FileAuditSink, RedactionPolicy

redaction = RedactionPolicy(
    sensitive_keys={"my_internal_token", "database_url", "password"},
)

guard = Edictum(
    audit_sink=FileAuditSink("audit.jsonl", redaction=redaction),
    redaction=redaction,
)

Custom Sink

Implement the AuditSink protocol -- a single async emit(event) method:

class MyAuditSink:
    async def emit(self, event):
        # event is an AuditEvent dataclass
        print(f"{event.action}: {event.tool_name}")

Operation Limits

Operation limits cap how many tool calls an agent can make in a session. Two counter types serve different purposes:

  • max_attempts caps all governance evaluations, including denied ones. This catches denial loops where an agent keeps retrying the same blocked call.
  • max_tool_calls caps only successful executions. This caps total work done.
  • max_calls_per_tool caps individual tools independently.
from edictum import Edictum, OperationLimits

guard = Edictum(
    limits=OperationLimits(
        max_attempts=100,
        max_tool_calls=50,
        max_calls_per_tool={"Bash": 20, "Write": 10},
    ),
)

When a limit fires, the denial message tells the agent to stop and reassess rather than retry.

Observe Mode

Set mode="observe" to run the full governance pipeline without blocking anything. The pipeline evaluates all rules, emits audit events, but converts denials to CALL_WOULD_DENY and allows the tool through.

Use this for:

  • Shadow deployment. Deploy Edictum alongside your agent, collect audit logs, and tune rules before switching to enforce mode.
  • Rule development. Write new preconditions and see what they'd block without disrupting the agent.
  • Compliance auditing. Record every tool call with governance evaluation results, even if you don't want to block anything yet.

The audit trail distinguishes three states:

Audit Action Meaning
CALL_ALLOWED Pipeline passed, tool executed
CALL_DENIED Pipeline denied, tool did not execute (enforce mode)
CALL_WOULD_DENY Pipeline denied, tool executed anyway (observe mode)

What's Next