Variable Interpolation
Contract messages support {placeholder} expansion -- every denial message can include the exact values that triggered it.
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:
- 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. - 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]. - 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.
| Placeholder | Source |
|---|---|
{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 Value | Coerced Type | Result |
|---|---|---|
"true" / "false" | bool | True / False |
"42" | int | 42 |
"3.14" | float | 3.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
| Placeholder | Source | Available In |
|---|---|---|
{environment} | Guard environment string | Pre, post, sandbox |
{tool.name} | Tool being called | Pre, post, sandbox |
{args.<key>} | Tool call arguments | Pre, post, sandbox |
{args.<key>.<nested>} | Nested argument values | Pre, post, sandbox |
{principal.user_id} | Principal identity | Pre, post, sandbox |
{principal.service_id} | Principal service | Pre, post, sandbox |
{principal.org_id} | Principal organization | Pre, post, sandbox |
{principal.role} | Principal role | Pre, post, sandbox |
{principal.ticket_ref} | Principal ticket reference | Pre, post, sandbox |
{principal.claims.<key>} | Principal claims (nested) | Pre, post, sandbox |
{output.text} | Tool output text | Post only |
{env.<VAR>} | OS environment variable | Pre, 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]:
| Pattern | Matches |
|---|---|
sk- followed by 20+ alphanumeric chars | OpenAI API keys |
AKIA followed by 16 uppercase chars | AWS access key IDs |
eyJ followed by 20+ base64 chars and . | JWTs |
ghp_ followed by 36 alphanumeric chars | GitHub personal access tokens |
xox[bpas]- followed by 10+ chars | Slack 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
- YAML reference -- full contract grammar including
messagefield - Message design patterns -- writing denial messages that steer agents
- Preconditions -- contract type that most commonly uses interpolation
- Custom operators -- registering custom selectors
Last updated on