Edictum
Reference

Variable Interpolation

Contract messages support {placeholder} expansion -- every denial message can include the exact values that triggered it.

AI Assistance

Right page if: you need to use {placeholder} tokens in YAML contract messages to include runtime values like args, principal, or environment in denial messages. Wrong page if: you need the full YAML contract grammar -- see https://docs.edictum.ai/docs/contracts/yaml-reference. For message design guidance, see https://docs.edictum.ai/docs/guides/writing-contracts. Gotcha: missing placeholders are kept as literal text (no crash, no empty string). Resolved values matching secret patterns (sk-*, AKIA*, eyJ*) are automatically replaced with [REDACTED]. Each expanded value is capped at 200 characters.

Contract messages support {placeholder} expansion. Every denial message can include the exact values that triggered it, giving the agent enough context to self-correct.


Quick Example

contracts:
  - id: block-sensitive-reads
    type: pre
    tools: [read_file]
    when:
      args.path: { matches: "\\.(env|pem|key)$" }
    then:
      effect: deny
      message: "Read of '{args.path}' denied for user {principal.user_id}. Use environment variables instead."

If an agent calls read_file(path=".env") with principal user_id: "alice", the denial message becomes:

Read of '.env' denied for user alice. Use environment variables instead.

How It Works

The pipeline scans every message field for {placeholder} tokens using the pattern \{([^}]+)\}. Each token is resolved against the tool call envelope. Three safety rules apply to every expansion:

  1. Missing fields stay as-is. If a placeholder references a field that does not exist on the envelope, the literal text {principal.user_id} appears in the output. No crash, no empty string.
  2. Secret values are redacted. If a resolved value matches a known secret pattern (OpenAI keys, AWS access keys, JWTs, GitHub tokens, Slack tokens), it is replaced with [REDACTED].
  3. Long values are truncated. Each expanded value is capped at 200 characters. Values longer than 200 characters are truncated with ... appended.

Selector Reference

Placeholders use the same selector paths as the when expression grammar. There are 7 built-in selector families.

environment

The environment string set on the guard instance (e.g., "production", "staging").

message: "Denied in {environment}. This tool is only available in staging."

tool.name

The name of the tool being called.

message: "Tool '{tool.name}' is not allowed for this role."

args.*

Tool call argument values. Supports nested paths through dicts via dot notation.

message: "Cannot read '{args.path}'."
message: "Timeout {args.config.timeout} exceeds the maximum."

Nested resolution works by splitting on . and traversing each key through the arguments dict. args.config.timeout resolves args["config"]["timeout"]. If any intermediate key is missing or the value is not a dict, the placeholder is kept as-is.

principal.*

Identity context fields from the principal attached to the tool call.

PlaceholderSource
{principal.user_id}Principal.user_id
{principal.service_id}Principal.service_id
{principal.org_id}Principal.org_id
{principal.role}Principal.role
{principal.ticket_ref}Principal.ticket_ref
{principal.claims.<key>}Nested lookup in Principal.claims dict
message: "User {principal.user_id} (role: {principal.role}) cannot deploy without ticket {principal.ticket_ref}."

Claims support nested paths: {principal.claims.department}, {principal.claims.clearance.level}.

output.text

The tool's output text. Only available in postcondition messages (postconditions run after the tool executes).

message: "PII detected in output of {tool.name}. First 200 chars: {output.text}"

env.*

OS environment variables read from os.environ at evaluation time.

message: "Denied in region {env.AWS_REGION}."

Environment variable values are type-coerced before expansion:

Raw ValueCoerced TypeResult
"true" / "false"boolTrue / False
"42"int42
"3.14"float3.14
"us-east-1"str"us-east-1" (unchanged)

Coercion is case-insensitive for booleans ("TRUE", "True", "true" all become True).

metadata.*

Custom metadata attached to the tool call envelope. Supports nested paths.

message: "Request {metadata.request_id} denied."

Complete Placeholder Table

PlaceholderSourceAvailable In
{environment}Guard environment stringPre, post, sandbox
{tool.name}Tool being calledPre, post, sandbox
{args.<key>}Tool call argumentsPre, post, sandbox
{args.<key>.<nested>}Nested argument valuesPre, post, sandbox
{principal.user_id}Principal identityPre, post, sandbox
{principal.service_id}Principal servicePre, post, sandbox
{principal.org_id}Principal organizationPre, post, sandbox
{principal.role}Principal rolePre, post, sandbox
{principal.ticket_ref}Principal ticket referencePre, post, sandbox
{principal.claims.<key>}Principal claims (nested)Pre, post, sandbox
{output.text}Tool output textPost only
{env.<VAR>}OS environment variablePre, post, sandbox
{metadata.<key>}Envelope metadata (nested)Pre, post, sandbox

Edge Cases

Missing Fields

Missing placeholders are never an error. The literal text is preserved:

# If no principal is attached to the call:
message: "Denied for {principal.user_id}."
# Output: "Denied for {principal.user_id}."

This is intentional. Contracts are often loaded once and shared across tool calls that may or may not have a principal. A missing field should not break the message.

Secret Redaction

If a resolved value matches any of these patterns, it is replaced with [REDACTED]:

PatternMatches
sk- followed by 20+ alphanumeric charsOpenAI API keys
AKIA followed by 16 uppercase charsAWS access key IDs
eyJ followed by 20+ base64 chars and .JWTs
ghp_ followed by 36 alphanumeric charsGitHub personal access tokens
xox[bpas]- followed by 10+ charsSlack tokens
# If args.api_key = "sk-abc123def456ghi789jkl012mno345"
message: "Key used: {args.api_key}"
# Output: "Key used: [REDACTED]"

This redaction protects against accidental secret leakage in audit logs and agent-visible denial messages.

Truncation

Values longer than 200 characters are truncated:

# If args.content is a 500-character string
message: "Content denied: {args.content}"
# Output: "Content denied: <first 197 chars>..."

The 200-character cap applies per placeholder, not to the total message length.

Null Values

If a selector resolves to None, the placeholder is kept as-is (same behavior as a missing field).


Custom Selectors

If you register custom selectors with the guard, they are available as placeholders too. Custom selectors are matched by prefix before the first dot:

def geo_resolver(envelope):
    return {"country": "US", "region": "us-east-1"}

guard = Edictum.from_yaml(
    "contracts.yaml",
    custom_selectors={"geo": geo_resolver},
)
message: "Denied in {geo.country} / {geo.region}."
# Output: "Denied in US / us-east-1."

Custom selector prefixes must not conflict with the 7 built-in families (environment, tool, args, principal, output, env, metadata).


Next Steps

Last updated on

On this page