Preconditions
Preconditions evaluate before a tool executes. If the condition matches, the call is denied and the tool never runs.
Right page if: you need to write preconditions that deny tool calls based on arguments, principal, or environment before execution. Wrong page if: you need to scan tool output after execution -- see https://docs.edictum.ai/docs/contracts/postconditions. For allowlist-based boundaries, see https://docs.edictum.ai/docs/concepts/sandbox-contracts. Gotcha: preconditions use `when`/`then` syntax with `effect: deny` (or `effect: approve` for human-in-the-loop). The `output.text` selector is invalid in preconditions and causes a load error -- that selector is postcondition-only.
Preconditions evaluate before a tool executes. If the condition matches, the call is denied and the tool never runs. This is the cheapest possible enforcement -- denial happens before any side effects, any API calls, any file writes. Nothing has happened yet, so there is nothing to undo.
- id: block-dotenv
type: pre
tool: read_file
when:
args.path: { contains: ".env" }
then:
effect: deny
message: "Read of sensitive file denied: {args.path}"This contract fires when read_file is called with a path argument containing ".env". The tool never executes. The agent receives the denial message and can try a different approach.
When to Use Preconditions
Preconditions answer one question: "Is this specific call dangerous?" Use them when:
- You have a short, stable list of things to deny. The patterns are known and finite --
.envreads,rm -rf, reverse shells, production deploys without a ticket. - The danger is in the input, not the output. If the problem is what the tool returns (PII, API keys), use a postcondition instead.
- You need zero-cost enforcement. Preconditions deny before execution, so there is no wasted compute, no API call to roll back, no side effect to clean up.
If the attack surface is open-ended -- infinite ways to read a file, infinite commands to run -- a precondition deny-list will grow forever. Use a sandbox contract to define what is allowed instead.
The when / then Structure
Every precondition has two blocks: when (the condition) and then (the action).
The when Block
when is an expression tree that evaluates against the tool call's arguments, principal, environment, and metadata. It supports boolean combinators (all, any, not) and 15 operators.
A simple condition uses a single selector and operator:
when:
args.path: { contains: ".env" }Compound conditions use boolean combinators:
when:
all:
- environment: { equals: production }
- principal.role: { not_in: [admin, sre] }
- principal.ticket_ref: { exists: false }This matches when all three sub-conditions are true: the environment is production, the principal's role is not admin or sre, and no ticket reference is attached.
Combinators nest arbitrarily:
when:
all:
- environment: { equals: production }
- any:
- principal.role: { equals: intern }
- principal.claims.department: { equals: marketing }
- not:
principal.claims.override_approved: { equals: true }Available Selectors
Selectors resolve fields from the tool call envelope and principal at evaluation time.
| Selector | Source | Example |
|---|---|---|
args.<key> | Tool call arguments | args.path, args.command, args.query |
args.<key>.<subkey> | Nested argument dicts | args.config.timeout |
tool.name | The tool being called | tool.name: { equals: "bash" } |
environment | The configured environment | environment: { equals: production } |
principal.user_id | Principal's user ID | principal.user_id: { equals: "alice" } |
principal.role | Principal's role | principal.role: { not_in: [admin, sre] } |
principal.service_id | Principal's service ID | principal.service_id: { exists: true } |
principal.org_id | Principal's organization | principal.org_id: { equals: "acme-corp" } |
principal.ticket_ref | Attached ticket reference | principal.ticket_ref: { exists: false } |
principal.claims.<key> | Custom claims on the principal | principal.claims.department: { equals: "engineering" } |
env.<VAR> | OS environment variable | env.FEATURE_FLAG_X: { equals: true } |
metadata.<key> | Per-call metadata from the envelope | metadata.risk_level: { gt: 7 } |
Missing fields evaluate to false. A contract checking args.path will not fire if the tool call has no path argument. The exists operator is the exception -- exists: false returns true for missing fields.
The output.text selector is not available in preconditions. The tool has not run yet, so there is no output to inspect. Using output.text in a precondition is a validation error at load time.
The then Block
then defines what happens when the condition matches:
then:
effect: deny
message: "Production changes require admin/sre role and a ticket."
tags: [change-control, production]
metadata:
severity: high
runbook: "https://runbooks.internal/prod-deploy"| Field | Required | Description |
|---|---|---|
effect | yes | deny or approve. See Effects. |
message | yes | Sent to the agent and recorded in the audit event. 1-500 characters. Supports {placeholder} expansion. |
tags | no | Classification labels for filtering in audit systems. |
metadata | no | Arbitrary key-value pairs stamped into the verdict and audit event. |
Effects
Preconditions support two effects: deny and approve.
effect: deny
The tool call is rejected. The tool function never executes. The agent receives the denial message.
- id: block-destructive-bash
type: pre
tool: bash
when:
any:
- args.command: { matches: '\brm\s+(-rf?|--recursive)\b' }
- args.command: { matches: '\bmkfs\b' }
- args.command: { matches: '\bdd\s+' }
- args.command: { contains: '> /dev/' }
then:
effect: deny
message: "Destructive command denied: '{args.command}'. Use a safer alternative."
tags: [destructive, safety]effect: approve
The pipeline pauses and requests human approval via the configured approval_backend. The tool does not execute until a human approves or the timeout expires.
- id: approve-prod-deploy
type: pre
tool: deploy_service
when:
all:
- environment: { equals: production }
- principal.role: { not_in: [admin] }
then:
effect: approve
message: "Production deploy by {principal.role} requires approval."
timeout: 300
timeout_effect: deny
tags: [change-control, production]| Field | Default | Description |
|---|---|---|
timeout | 300 | Seconds to wait for an approval decision. |
timeout_effect | deny | What happens when the approval times out. deny or allow. |
The approval is routed through Edictum Console, which delivers it to a human reviewer via Slack, email, or the console dashboard. If no approval_backend is configured on the Edictum instance, effect: approve raises EdictumDenied immediately.
Tool Targeting
The tool field determines which tool calls the precondition evaluates against.
Exact match -- targets a single tool:
- id: block-dotenv
type: pre
tool: read_file
when:
args.path: { contains: ".env" }
then:
effect: deny
message: "Denied: {args.path}"Wildcard -- targets all tools:
- id: block-production-all
type: pre
tool: "*"
when:
all:
- environment: { equals: production }
- principal.role: { equals: intern }
then:
effect: deny
message: "Interns cannot use tools in production."Glob pattern -- targets tools matching a pattern using Python's fnmatch:
- id: block-mcp-writes
type: pre
tool: "mcp_*"
when:
args.operation: { in: [write, delete, update] }
then:
effect: deny
message: "Write operations on MCP tools are denied."This matches mcp_filesystem, mcp_database, mcp_slack, and any other tool starting with mcp_.
Variable Interpolation in Messages
Messages support {placeholder} expansion from the envelope context. This makes denial messages specific and actionable -- the agent knows exactly what was denied and why.
message: "Read of '{args.path}' denied for user {principal.user_id} in {environment}."Available placeholders follow the same selector paths as the expression grammar: {args.path}, {args.command}, {tool.name}, {environment}, {principal.user_id}, {principal.role}, {principal.ticket_ref}, {env.VAR_NAME}, and so on.
If a placeholder references a missing field, it is kept as-is in the output -- no crash, no empty string. Each placeholder expansion is capped at 200 characters.
Write messages that steer the agent. A denial message is not just a log entry -- it is the agent's primary feedback on what went wrong and what to do instead:
| Message | Quality |
|---|---|
"Denied." | Bad -- the agent has no context |
"Read of sensitive file denied: {args.path}" | Better -- the agent knows what was denied |
"Read of '{args.path}' denied. Use environment variables instead." | Best -- the agent knows the alternative |
Observe Mode
When mode: observe is set on a precondition, the contract evaluates but does not deny. A matching precondition emits a CALL_WOULD_DENY audit event instead. The tool call proceeds normally.
- id: experimental-api-check
type: pre
mode: observe
tool: call_api
when:
args.endpoint: { contains: "/v1/expensive" }
then:
effect: deny
message: "Expensive API call detected (observe mode)."
tags: [cost, experimental]Use observe mode to test a new precondition against live traffic before enforcing it. The audit trail shows what would have been denied, so you can verify the contract catches the right things without disrupting the agent.
Set the default for all contracts in the bundle with defaults.mode, or override per-contract:
defaults:
mode: enforce
contracts:
- id: proven-contract
type: pre
# inherits mode: enforce from defaults
tool: read_file
when:
args.path: { contains: ".env" }
then:
effect: deny
message: "Denied: {args.path}"
- id: new-contract-under-test
type: pre
mode: observe
tool: bash
when:
args.command: { matches: '\bcurl\b' }
then:
effect: deny
message: "Curl detected (observing)."For a full walkthrough of the observe-to-enforce workflow, see observe mode.
Common Patterns
Sensitive File Protection
Block reads of files containing secrets, credentials, and keys:
- id: block-sensitive-reads
type: pre
tool: read_file
when:
args.path:
contains_any: [".env", ".secret", "credentials", ".pem", "id_rsa", "kubeconfig"]
then:
effect: deny
message: "Sensitive file '{args.path}' denied. Skip and continue."
tags: [secrets, dlp]Environment Gate with Role Check
Require a privileged role for production operations:
- id: prod-deploy-requires-senior
type: pre
tool: deploy_service
when:
all:
- environment: { equals: production }
- principal.role: { not_in: [senior_engineer, sre, admin] }
then:
effect: deny
message: "Production deploys require senior role (sre/admin). Your role: {principal.role}."
tags: [change-control, production]Ticket Requirement
Ensure production changes are traceable to an approved ticket:
- id: prod-requires-ticket
type: pre
tool: deploy_service
when:
all:
- environment: { equals: production }
- principal.ticket_ref: { exists: false }
then:
effect: deny
message: "Production changes require a ticket reference. Attach a ticket_ref to the principal."
tags: [change-control, compliance]Reverse Shell Prevention
Block known reverse shell patterns in bash commands:
- id: block-reverse-shells
type: pre
tool: bash
when:
any:
- args.command: { matches: '/dev/tcp/' }
- args.command: { matches: '\bnc\s+.*-e\b' }
- args.command: { matches: '\bbash\s+-i\b' }
- args.command: { matches: '\bpython[23]?\s+-c\s+.*socket\b' }
then:
effect: deny
message: "Reverse shell pattern denied."
tags: [security, exfiltration]Blast Radius Limits
Cap the size of dangerous operations:
- id: limit-batch-delete
type: pre
tool: delete_records
when:
args.batch_size: { gt: 100 }
then:
effect: deny
message: "Batch delete of {args.batch_size} records exceeds the limit of 100."
tags: [safety, blast-radius]Feature Flag Gate
Use environment variables to gate tool access without redeploying contracts:
- id: feature-gate-new-api
type: pre
tool: call_new_api
when:
env.ENABLE_NEW_API: { not_equals: true }
then:
effect: deny
message: "New API is disabled. Set ENABLE_NEW_API=true to enable."
tags: [feature-flag]Pipeline Position
Preconditions run first in the pipeline. The full evaluation order is:
- Attempt limits -- is the session's attempt counter exceeded?
- Before hooks -- registered Python
beforecallbacks - Preconditions -- YAML
type: precontracts (this page) - Sandbox contracts -- allowlist boundaries for file paths, commands, domains
- Session contracts -- cumulative usage limits
- Tool execution -- the tool runs
- Postconditions -- YAML
type: postcontracts inspect the output - Audit event -- emitted for every evaluation
If a precondition denies the call, steps 4-7 never happen. The tool never executes, sandbox and session contracts are not evaluated, and no postcondition runs. This is why preconditions are the most efficient enforcement point -- everything downstream is skipped.
Preconditions compose naturally with other contract types. A single bundle can use all four:
contracts:
# Precondition: catch known-bad patterns
- id: block-reverse-shells
type: pre
tool: bash
when:
args.command: { matches: '/dev/tcp/' }
then:
effect: deny
message: "Reverse shell pattern denied."
# Sandbox: deny everything outside the allowlist
- id: exec-sandbox
type: sandbox
tool: bash
allows:
commands: [git, npm, node, python, pytest]
outside: deny
message: "Command not in allowlist: {args.command}"
# Postcondition: scan output for secrets
- id: secrets-in-output
type: post
tool: "*"
when:
output.text: { matches: 'sk-prod-[a-z0-9]{8}' }
then:
effect: redact
message: "Secrets detected and redacted."
# Session: cap total calls
- id: session-limits
type: session
limits:
max_tool_calls: 50
then:
effect: deny
message: "Session limit reached."Preconditions catch known-bad patterns (reverse shells). The sandbox catches unknown-bad (any command not in the allowlist). Together, they provide belt and suspenders: the deny-list handles the patterns you know about, the sandbox handles everything else.
Error Handling
Preconditions follow Edictum's fail-closed design:
| Scenario | Behavior |
|---|---|
| Selector references a missing field | Leaf evaluates to false. The contract does not fire. |
Type mismatch (e.g., gt on a string) | Contract fires with policy_error: true. The call is denied. |
| Contract evaluation throws | Contract yields deny with policy_error: true. |
output.text used in a precondition | Validation error at load time. |
Invalid regex in matches | Validation error at load time. |
When in doubt, the contract fires. This is intentional -- a false positive (denying a safe call) is recoverable. A false negative (allowing a dangerous call) may not be.
Next Steps
- Postconditions -- inspect tool output after execution
- Session contracts -- cumulative usage limits across turns
- Sandbox contracts -- allowlist-based enforcement for file paths, commands, and domains
- YAML reference -- full contract syntax and schema
- Operators -- all 15 operators with examples
- How the pipeline works -- the full evaluation order
- Contract patterns -- real-world patterns for access control, change control, and more
Last updated on