Edictum
Contracts Reference

Preconditions

Preconditions evaluate before a tool executes. If the condition matches, the call is denied and the tool never runs.

AI Assistance

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 -- .env reads, 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.

SelectorSourceExample
args.<key>Tool call argumentsargs.path, args.command, args.query
args.<key>.<subkey>Nested argument dictsargs.config.timeout
tool.nameThe tool being calledtool.name: { equals: "bash" }
environmentThe configured environmentenvironment: { equals: production }
principal.user_idPrincipal's user IDprincipal.user_id: { equals: "alice" }
principal.rolePrincipal's roleprincipal.role: { not_in: [admin, sre] }
principal.service_idPrincipal's service IDprincipal.service_id: { exists: true }
principal.org_idPrincipal's organizationprincipal.org_id: { equals: "acme-corp" }
principal.ticket_refAttached ticket referenceprincipal.ticket_ref: { exists: false }
principal.claims.<key>Custom claims on the principalprincipal.claims.department: { equals: "engineering" }
env.<VAR>OS environment variableenv.FEATURE_FLAG_X: { equals: true }
metadata.<key>Per-call metadata from the envelopemetadata.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"
FieldRequiredDescription
effectyesdeny or approve. See Effects.
messageyesSent to the agent and recorded in the audit event. 1-500 characters. Supports {placeholder} expansion.
tagsnoClassification labels for filtering in audit systems.
metadatanoArbitrary 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]
FieldDefaultDescription
timeout300Seconds to wait for an approval decision.
timeout_effectdenyWhat 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:

MessageQuality
"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:

  1. Attempt limits -- is the session's attempt counter exceeded?
  2. Before hooks -- registered Python before callbacks
  3. Preconditions -- YAML type: pre contracts (this page)
  4. Sandbox contracts -- allowlist boundaries for file paths, commands, domains
  5. Session contracts -- cumulative usage limits
  6. Tool execution -- the tool runs
  7. Postconditions -- YAML type: post contracts inspect the output
  8. 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:

ScenarioBehavior
Selector references a missing fieldLeaf 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 throwsContract yields deny with policy_error: true.
output.text used in a preconditionValidation error at load time.
Invalid regex in matchesValidation 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

Last updated on

On this page