Tutorial: Creating Contracts¶
This guide walks through the full workflow of creating, validating, and deploying an Edictum contract -- from requirement to production enforcement.
When to use this¶
Start here when you need to turn an informal restriction into a working YAML contract. This guide walks through the full authoring workflow -- translating a requirement into selectors and operators, validating the result with edictum check, deploying in observe mode, and flipping to enforce once you are confident. It also covers common pitfalls like missing principal fields, regex escaping in YAML, and choosing the right operator. For testing contracts once they are written, see Testing contracts. For postcondition-specific design, see Postcondition design.
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-- block the call. This is the only valid effect for preconditions.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:
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: 1
Verify 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 logged
Step 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:
Now 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).