⚠️ mlld is pre-release
Join the discord if you have questions. Report bugs on GitHub.
Security
tldr
Guards protect data and operations. Label sensitive data, define guards to control access:
/var secret @apiKey = "sk-live-12345"
/guard @noShellSecrets before secret = when [
@ctx.op.type == "run" => deny "Secrets cannot appear in shell commands"
* => allow
]
/run cmd { echo @apiKey } # Blocked by guard
Inline effects (| output, | show, | append, | log) use the same guard path as directives. Guard filters op:output/op:show/op:append/op:log cover both inline effects and directives.
Data Labels
Mark data as sensitive by adding labels to variable declarations:
/var secret @apiKey = "sk-12345" # Labeled 'secret'
/var pii @email = "user@example.com" # Labeled 'pii'
/var secret,pii @ssn = "123-45-6789" # Multiple labels (comma-separated, no spaces)
Labels track through operations:
/var secret @token = "sk-12345"
/var @trimmed = @token.trim() # Still labeled 'secret'
/var @partial = @token.slice(0, 5) # Still labeled 'secret'
/var @upper = @token.toUpperCase() # Still labeled 'secret'
Check labels with .ctx.labels:
/var secret @data = "sensitive"
/show @data.ctx.labels # ["secret"]
Guards
Guards enforce policies on labeled data or operations.
Basic Guard Syntax
/guard [@name] TIMING LABEL = when [
CONDITION => ACTION
* => allow
]
Where:
@nameis optional guard nameTIMINGis required:before,after, oralwaysLABELis a data label (secret,pii) or operation filter (op:run,op:exe)
Syntactic sugar: /guard [@name] for LABEL = when [...] is equivalent to before timing. Using explicit before is recommended for clarity.
Actions:
allow- Operation proceedsdeny "reason"- Operation blockedretry "hint"- Retry operation (pipelines only)allow @value- Transform and allow
Guard on Data Labels
Block secrets from shell commands:
/guard @noShellSecrets before secret = when [
@ctx.op.type == "run" => deny "Secrets cannot appear in shell"
* => allow
]
/var secret @key = "sk-12345"
/run cmd { echo @key } # Blocked
Guard on Operations
Block all shell commands regardless of data:
/guard @noShell before op:run = when [
* => deny "Shell access disabled"
]
/run cmd { ls } # Blocked
Filter by operation name:
/guard @blockSend before op:exe = when [
@ctx.op.name == "sendData" => deny "Network calls blocked"
* => allow
]
/exe @sendData(value) = run { curl -d "@value" api.example.com }
/show @sendData("test") # Blocked
Denied Handlers
Handle guard denials gracefully with denied => branches:
/guard @secretBlock before secret = when [
@ctx.op.type == "show" => deny "Cannot display secrets"
* => allow
]
/var secret @key = "sk-12345"
/exe @display(value) = when [
denied => `[REDACTED] - @ctx.guard.reason`
* => `Value: @value`
]
/show @display(@key) # Shows: [REDACTED] - Cannot display secrets
Access guard context in denied handlers:
/exe @handler(value) = when [
denied => show "Blocked: @ctx.guard.reason"
denied => show "Guard: @ctx.guard.name"
denied => show "Labels: @ctx.labels.join(', ')"
* => show @value
]
Before Guards (Input Validation)
Before guards check inputs before operations execute:
/guard @validateInput before op:exe = when [
@input.length > 1000 => deny "Input too large"
@input.includes("<script") => deny "Potentially malicious input"
* => allow
]
/exe @process(data) = run { echo "@data" }
/show @process("<script>alert('xss')</script>") # Blocked
Transform inputs with allow @value:
/guard @sanitize before untrusted = when [
* => allow @input.trim().slice(0, 100)
]
/var untrusted @userInput = " very long input... "
/exe @process(data) = `Processed: @data`
/show @process(@userInput) # Input trimmed and truncated
After Guards (Output Validation)
After guards validate outputs after operations complete:
/guard @validateOutput after op:exe = when [
@output.includes("ERROR") => deny "Operation failed"
* => allow
]
/exe @query() = run { curl api.example.com/status }
/show @query() # Blocked if output contains ERROR
Sanitize outputs:
/guard @redactSecrets after op:exe = when [
@output.includes("sk-") => allow @output.replace(/sk-[a-zA-Z0-9]+/g, '[REDACTED]')
* => allow
]
/exe @getStatus() = run { echo "Status: ok, key: sk-12345" }
/show @getStatus() # Output: Status: ok, key: [REDACTED]
Check LLM output:
/guard @validateJson after op:exe = when [
@isValidJson(@output) => allow
* => deny "LLM did not return valid JSON"
]
/exe @isValidJson(text) = js { try { JSON.parse(text); return true; } catch { return false; } }
Guard Timing
Guards can run before, after, or both:
/guard @checkInput before secret = when [...] # Before operation
/guard @checkOutput after secret = when [...] # After operation
/guard @checkBoth always secret = when [...] # Both before and after
Use @ctx.guard.timing to differentiate:
/guard @tag always op:exe = when [
* => allow @tagValue(@ctx.guard.timing, @output, @input)
]
/exe @tagValue(timing, out, in) = js {
const val = out ?? in ?? '';
return `${timing}:${val}`;
}
/exe @emit(v) = js { return v; }
/show @emit("test") # Output: after:before:test
Guard Composition
Multiple guards execute in order (top-to-bottom in file):
/guard @first before secret = when [
* => allow @input.trim()
]
/guard @second before secret = when [
* => allow `safe:@input`
]
/var secret @data = " hello "
/exe @deliver(v) = `Result: @v`
>> Result: safe:hello
/show @deliver(@data)
Decision precedence: deny > retry > allow @value > allow
/guard @retryGuard before secret = when [
* => retry "need retry"
]
/guard @denyGuard before secret = when [
* => deny "hard stop"
]
# deny wins, but retry hint preserved in @ctx.guard.hints
Guard Transforms
Guards can transform data with allow @value:
/exe @redact(text) = js { return text.replace(/./g, '*'); }
/guard @redactSecrets before secret = when [
@ctx.op.type == "show" => allow @redact(@input)
* => allow
]
/var secret @key = "sk-12345"
/show @key # Output: *********
Transforms chain across multiple guards:
/guard @trim before secret = when [
* => allow @input.trim()
]
/guard @wrap before secret = when [
* => allow `[REDACTED: @input]`
]
/var secret @key = " sk-12345 "
/show @key # Output: [REDACTED: sk-12345]
Guard Context
Access guard evaluation context with @ctx.guard.*:
In Guard Expressions
/guard @retryOnce before op:exe = when [
@ctx.guard.try == 1 => retry "first attempt failed"
@ctx.guard.try == 2 => retry "second attempt failed"
* => allow
]
In Denied Handlers
/exe @process(value) = when [
denied => show "Blocked by: @ctx.guard.name"
denied => show "Reason: @ctx.guard.reason"
denied => show "Decision: @ctx.guard.decision"
denied => show "All reasons: @ctx.guard.reasons.join(', ')"
* => show @value
]
Common Properties
@ctx.guard.try- Current attempt number (1, 2, 3...)@ctx.guard.max- Max attempts allowed (default 3)@ctx.guard.reason- Primary denial/retry reason@ctx.guard.reasons- All reasons from guard chain@ctx.guard.hints- Retry hints from guards@ctx.guard.trace- Full guard evaluation trace@ctx.guard.timing- "before" or "after"@ctx.guard.name- Guard name@ctx.labels- Data labels on input
See full reference in @ctx.guard section below.
Guard Overrides
Selectively control guards per operation:
Disable all guards:
/guard @block before secret = when [
* => deny "blocked"
]
/var secret @data = "test"
/show @data with { guards: false } # Guards disabled (warning emitted)
Skip specific guards:
/guard @blocker before secret = when [
* => deny "should skip"
]
/guard @allowed before secret = when [
* => allow
]
/var secret @data = "visible"
/show @data with { guards: { except: ["@blocker"] } } # Only @allowed runs
Run only specific guards:
/show @data with { guards: { only: ["@specific"] } }
Guard Import/Export
Define guards in modules:
# guards/secrets.mld
/guard @secretProtection before secret = when [
@ctx.op.type == "run" => deny "Secrets blocked from shell"
* => allow
]
/export { @secretProtection }
Import and use:
/import { @secretProtection } from "./guards/secrets.mld"
/var secret @key = "sk-12345"
/run cmd { echo @key } # Protected by imported guard
Expression Tracking
Guards see labels through all transformations:
/guard @secretBlock before secret = when [
@ctx.op.type == "show" => deny "No secrets"
* => allow
]
/var secret @key = " sk-12345 "
# All of these preserve 'secret' label:
/show @key.trim() # Blocked
/show @key.slice(0, 5) # Blocked
/show @key.toUpperCase() # Blocked
/show @key.trim().slice(0, 3).toUpperCase() # Blocked
Labels track through:
- Chained builtin methods (
.trim().slice()) - Template interpolation (
`text @secret`) - Field access (
@obj.secret.field) - Iterators (
for @item in @secrets) - Pipelines (all stages)
- Nested expressions
Common Patterns
Redact Secrets for Display
/exe @redact(text) = js { return text.slice(0, 4) + '****'; }
/guard @redactSecrets before secret = when [
@ctx.op.type == "show" => allow @redact(@input)
* => allow
]
/var secret @key = "sk-12345678"
/show @key # Output: sk-1****
Validate LLM Output
/exe @isValidJson(text) = js {
try { JSON.parse(text); return true; }
catch { return false; }
}
/guard @validateJson after op:exe = when [
@ctx.op.name == "llmCall" && !@isValidJson(@output) => deny "Invalid JSON from LLM"
* => allow
]
Block Network Access
/guard @noNetwork before op:run = when [
@ctx.op.subtype == "sh" => deny "Shell access blocked"
* => allow
]
/guard @noExecNetwork before op:exe = when [
@input.any.ctx.labels.includes("network") => deny "Network calls blocked"
* => allow
]
Sanitize Untrusted Input
/exe @sanitize(text) = js {
return text
.replace(/<script[^>]*>.*?<\/script>/gi, '')
.replace(/javascript:/gi, '')
.trim();
}
/guard @sanitizeUntrusted before untrusted = when [
* => allow @sanitize(@input)
]
/var untrusted @userInput = "<script>alert('xss')</script>Hello"
/show @userInput # Output: Hello (sanitized)
Operation-Specific Guards
/guard @fileWritePolicy before secret = when [
@ctx.op.type == "output" => deny "Cannot write secrets to files"
* => allow
]
/guard @displayPolicy before secret = when [
@ctx.op.type == "show" => allow @redact(@input)
* => allow
]
@ctx.guard Reference
Properties available in guard expressions and denied handlers:
Attempt Tracking
@ctx.guard.try- Current attempt (1, 2, 3...)@ctx.guard.max- Max attempts (default 3)@ctx.guard.tries- Previous attempt history
Guard Identity
@ctx.guard.name- Guard name (or null for anonymous)@ctx.guard.timing- "before" or "after"
Input/Output
@input- Input value being guarded (also@ctx.guard.input)@output- Output value (after guards only, also@ctx.guard.output)
Decision Info (Denied Handlers Only)
@ctx.guard.decision- Final decision ("allow", "deny", "retry")@ctx.guard.reason- Primary denial/retry reason@ctx.guard.reasons- All reasons from guard chain@ctx.guard.hints- Retry hints from guards@ctx.guard.trace- Full guard evaluation results
Data Context
@ctx.labels- Data labels on input@ctx.sources- Source provenance
Guard Retry
Guards can retry operations in pipeline contexts:
/guard before secret = when [
@ctx.op.type == "pipeline-stage" && @ctx.guard.try == 1 => retry "Try again"
* => allow
]
/exe @mask(v) = js { return v.replace(/.(?=.{4})/g, '*'); }
/var secret @key = "sk-12345"
/var @safe = @key with { pipeline: [@mask] }
/show @safe # Retries once, then succeeds
Retry budget is shared across guard chain (max 3 attempts).
Best Practices
Label sensitive data early:
/var secret @apiKey = <.env>
/var pii @userData = <users.json>
Use operation-level guards for broad policies:
/guard @noShell before op:run = when [
* => deny "Shell disabled in production"
]
Use data-level guards for specific protections:
/guard @secretProtection before secret = when [
@ctx.op.type == "run" => deny "No secrets in shell"
@ctx.op.type == "output" => deny "No secrets to files"
* => allow
]
Always handle denials in production code:
/exe @handler(value) = when [
denied => show "Operation blocked: @ctx.guard.reason"
denied => "fallback-value"
* => @value
]
Transform instead of deny when possible:
/guard @redactSecrets before secret = when [
@ctx.op.type == "show" => allow @redact(@input)
* => allow
]
Guard Helpers
mlld provides helpers in guard contexts:
@prefixWith(label, value)
Add prefix to values:
/guard @tag before op:exe = when [
* => allow @prefixWith("tagged", @input)
]
@tagValue(timing, output, input)
Tag based on guard timing:
/guard @tag always op:exe = when [
* => allow @tagValue(@ctx.guard.timing, @output, @input)
]
@input.any / @input.all / @input.none
Array quantifiers for per-operation guards:
/guard @blockSecretsInRun before op:run = when [
@input.any.ctx.labels.includes("secret") => deny "Shell cannot access secrets"
@input.all.ctx.tokest < 1000 => allow
@input.none.ctx.labels.includes("pii") => allow
* => deny "Input validation failed"
]
Security Model
mlld's security is based on three pillars:
Data Labels - Tag sensitive data
/var secret @key = "sk-12345"
Guards - Enforce policies
/guard before secret = when [
@ctx.op.type == "run" => deny "No shell access"
* => allow
]
Context - Access metadata
@ctx.labels # Data labels
@ctx.guard.reason # Guard decisions
@ctx.op.type # Operation type
Guards are:
- Non-reentrant - Don't fire during guard evaluation (prevents infinite loops)
- Ordered - Execute top-to-bottom in file, imports flatten at position
- Composable - All guards run, decisions aggregate with precedence
Labels propagate through:
- Builtin methods, template interpolation, field access
- Pipelines, iterators, nested expressions
- Transform chains, guard evaluations