Tutorial: Creating Contracts
This guide walks through the full workflow of creating, validating, and deploying an Edictum contract -- from requirement to production enforcement.
Right page if: you are turning an informal restriction into a working YAML contract for the first time, or need the full authoring workflow (write, validate, observe, enforce). Wrong page if: you already have contracts and need to test them -- see https://docs.edictum.ai/docs/guides/testing-contracts. For postcondition-specific design, see https://docs.edictum.ai/docs/guides/postcondition-design. Gotcha: YAML double-quoted strings interpret \b as backspace, not a regex word boundary. Always use single quotes for regex patterns. Missing principal fields silently evaluate to false, so contracts never fire.
This guide walks through the full workflow of creating, validating, and deploying an Edictum contract -- from requirement to production enforcement.
Step 1: Start With a Requirement
Suppose your team has this requirement:
Analysts should not be able to read secret files like
.env,.pem, or credential files.
This is a precondition -- you want to block the tool call before it executes.
Step 2: Translate to a YAML Contract
Create a file called contracts.yaml with a complete ContractBundle:
apiVersion: edictum/v1
kind: ContractBundle
metadata:
name: analyst-file-policy
description: "Prevent analysts from reading secret files."
defaults:
mode: observe
contracts:
- id: block-secret-reads
type: pre
tool: read_file
when:
all:
- args.path:
contains_any: [".env", ".secret", "credentials", ".pem", "id_rsa"]
- principal.role:
equals: analyst
then:
effect: deny
message: "Analysts cannot read '{args.path}'. Ask an admin for help."
tags: [secrets, dlp]Key decisions in this contract:
type: pre-- evaluate before the tool runs.tool: read_file-- only applies to theread_filetool.all-- both conditions must be true (sensitive path AND analyst role).effect: deny-- deny the call. Preconditions also supporteffect: approvefor approval gates.mode: observein defaults -- start by observing, not enforcing.
Step 3: Validate the Contract
Run the CLI validator to catch syntax and schema errors before deployment:
$ edictum validate contracts.yaml
contracts.yaml — 1 contract (1 pre)If there are errors (bad regex, wrong effect, duplicate IDs), the validator reports them and exits with code 1.
Step 4: Test With edictum check
Simulate a tool call against the contract without executing anything:
$ edictum check contracts.yaml \
--tool read_file \
--args '{"path": ".env"}' \
--principal-role analyst
DENIED by contract block-secret-reads
Message: Analysts cannot read '.env'. Ask an admin for help.
Tags: secrets, dlp
Contracts evaluated: 1Verify that allowed calls pass:
$ edictum check contracts.yaml \
--tool read_file \
--args '{"path": "readme.txt"}' \
--principal-role analyst
ALLOWED
Contracts evaluated: 1 contract(s)Step 5: Deploy in Observe Mode
Notice that defaults.mode is set to observe. In this mode, Edictum logs what would be denied without actually denying anything. This is safe for production rollout.
from edictum import Edictum, Principal
from edictum.adapters.langchain import LangChainAdapter
guard = Edictum.from_yaml("contracts.yaml")
adapter = LangChainAdapter(
guard=guard,
principal=Principal(user_id="alice", role="analyst"),
)
middleware = adapter.as_middleware()
# Tool calls proceed normally, but denials are loggedStep 6: Review Audit Logs
In observe mode, denied calls produce call_would_deny audit events. Review them to confirm the contract fires on the right calls and not on legitimate ones:
{
"action": "call_would_deny",
"tool_name": "read_file",
"decision_name": "block-secret-reads",
"tool_args": {"path": ".env"},
"principal": {"user_id": "alice", "role": "analyst"},
"reason": "Analysts cannot read '.env'. Ask an admin for help."
}Check for:
- False positives -- legitimate calls that would be denied.
- False negatives -- calls that should be denied but are not.
- Missing principal fields -- if
principal.roleis null, the leaf evaluates tofalseand the contract never fires.
Step 7: Flip to Enforce
Once you are confident in the contract behavior, change mode from observe to enforce:
defaults:
mode: enforceNow denied calls are enforced. The tool callable is never invoked, and the agent sees the denial message.
Common Mistakes
Wrong operator
Using equals when you need contains:
# Wrong -- only matches if the entire path is literally ".env"
args.path:
equals: ".env"
# Right -- matches any path containing ".env"
args.path:
contains: ".env"Missing principal field
If the principal does not have a role field set, selectors like principal.role resolve to null. A null selector causes the leaf to evaluate to false, so the contract never fires. The call is silently allowed.
Fix: ensure the principal is populated when creating the adapter:
principal = Principal(user_id="alice", role="analyst")
adapter = LangChainAdapter(guard=guard, principal=principal)Regex escaping in YAML
YAML double-quoted strings interpret escape sequences. "\b" is a backspace character, not a word boundary. Always use single quotes for regex:
# Wrong -- "\b" is backspace
args.command:
matches: "\brm\b"
# Right -- '\b' is literal backslash-b (word boundary)
args.command:
matches: '\brm\b'Using output.text in preconditions
The output.text selector is only available in postconditions (after the tool has run). Using it in a precondition is a validation error at load time:
# Wrong -- output.text does not exist before the tool runs
- id: bad-pre
type: pre
tool: read_file
when:
output.text:
contains: "SECRET"
then:
effect: deny
message: "..."Postcondition effects
Since v0.6.0, postconditions support three effects:
| Effect | What happens |
|---|---|
warn | Emit a finding. Output passes through unchanged. Handle with on_postcondition_warn callback. |
redact | Replace regex-matched patterns in the output with [REDACTED]. |
deny | Replace the entire tool output with [OUTPUT SUPPRESSED]. |
# Redact SSNs from output
- id: redact-ssn
type: post
tool: "*"
when:
output.text:
matches: '\b\d{3}-\d{2}-\d{4}\b'
then:
effect: redact
message: "SSN pattern redacted from output."The effect you choose depends on the severity: warn for logging, redact for targeted cleanup, deny for full suppression when any match means the output is unsafe.
Writing Sandbox Contracts
When deny-lists grow too long or bypass vectors keep appearing, switch to sandbox contracts. Instead of listing what's bad, define what's allowed.
Example: File Path Sandbox
Requirement: the agent should only read/write files in /workspace and /tmp, never in /workspace/.git.
- id: file-sandbox
type: sandbox
tools: [read_file, write_file, edit_file]
within:
- /workspace
- /tmp
not_within:
- /workspace/.git
outside: deny
message: "File access outside workspace: {args.path}"Key differences from preconditions:
- No
when/thenstructure. Sandbox uses declarative boundary fields. toolsaccepts a list. One sandbox contract covers multiple tools.within+not_withindefine path allowlists, not pattern denylists.outsidecontrols the effect:denyorapprove(human approval gate).
Command and Domain Allowlists
Sandbox contracts also support command and domain boundaries:
- id: network-sandbox
type: sandbox
tool: fetch_url
allows:
domains: [api.example.com, cdn.example.com]
not_allows:
domains: [internal.example.com]
outside: deny
message: "Domain not in allowlist: {args.url}"When to Use Sandbox vs. Precondition
| Use... | When... |
|---|---|
Precondition (type: pre) | You have a short, stable list of things to deny (rm -rf /, reverse shells, .env reads). The list does not grow with every red team. |
Sandbox (type: sandbox) | The attack surface is open-ended. You would rather define what is allowed. New bypasses are denied by default. |
They compose: deny runs first (catch known-bad), sandbox runs second (catch unknown-bad).
Last updated on