Configuration includes project settings, environment variables, frontmatter, SDK modes, and checkpoint/resume for caching LLM call results.
Config Files
mlld uses dual configuration:
mlld-config.json- Your project settings (edit manually)mlld-lock.json- Auto-generated locks (don't edit)
mlld validate warning suppression lives in mlld-config.json:
{
"validate": {
"suppressWarnings": ["exe-parameter-shadowing"]
}
}
Use suppression when a warning is intentional and reviewed.
Paths and URLs
Paths can be literal, interpolated, or resolver-based.
var @dir = "./docs"
var @userFile = "data/@username/profile.json"
var @template = 'templates/@var.html' >> literal '@'
>> URLs as sources
show <https://raw.githubusercontent.com/org/repo/main/README.md>
var @remote = <https://example.com/README.md>
@root resolution: @root (alias: @base) resolves to the project root by walking up from the current file's directory looking for mlld-config.json, mlld-lock.json, package.json, .git, or similar project markers. Use <@root/path> for project-absolute paths. See mlld howto builtins-reserved-variables for the full resolution algorithm.
Environment Variables
Allow env vars in config, then import via @input.
mlld-config.json:
{
"security": {
"allowedEnvVars": ["MLLD_NODE_ENV", "MLLD_API_KEY", "MLLD_GITHUB_TOKEN"]
}
}
Usage:
import { @MLLD_NODE_ENV, @MLLD_API_KEY } from @input
show `Running in @MLLD_NODE_ENV`
All env vars must be prefixed with MLLD_.
policy
A policy object combines all security configuration into a single declaration.
policy @p = {
defaults: {
rules: [
"no-secret-exfil",
"no-sensitive-exfil",
"no-untrusted-destructive",
"no-untrusted-privileged"
]
},
operations: {
exfil: ["net:w"],
destructive: ["fs:w"],
privileged: ["sys:admin"]
},
auth: {
claude: "ANTHROPIC_API_KEY"
},
capabilities: {
allow: ["cmd:git:*"],
danger: ["@keychain"]
}
}
defaults sets baseline behavior. rules enables built-in security rules that block dangerous label-to-operation flows. unlabeled optionally auto-labels all data that has no user-assigned labels -- set to "untrusted" to treat unlabeled data as untrusted, or "trusted" to treat it as trusted. This is opt-in; without it, unlabeled data has no trust label.
Built-in positional rules use the same defaults.rules list. no-send-to-unknown checks operations labeled exfil:send and requires the first positional argument to carry known. no-send-to-external is the stricter send variant and requires known:internal. no-destroy-unknown checks operations labeled destructive:targeted and requires the first positional argument to carry known, which is useful for delete/cancel/remove flows where the target must be explicitly approved.
mlld validate warns on unknown built-in rule names in defaults.rules and suggests the closest known rule when it can.
locked makes all managed label-flow denials from this policy non-overridable, even by explicit privileged guards. Without locked: true (the default), a privileged guard can override policy label-flow denials with allow for specific operations. Use locked: true for absolute constraints that nothing should bypass.
policy @p = {
defaults: { rules: ["no-secret-exfil"] },
locked: true
}
operations groups semantic exe labels under risk categories. You label functions with what they DO (net:w, fs:w), and policy classifies those as risk types (exfil, destructive). This is the two-step pattern -- see policy-operations.
mlld analyze --format json surfaces these mappings under policies[].operations, and mlld validate --context ... can warn when privileged op: guards do not match any declared operation labels in the validation context.
auth defines caller-side credential mappings for using auth:name. It accepts short form ("API_KEY") and object form ({ from, as }). Policy auth composes with standalone auth; caller policy entries override same-name module bindings.
capabilities controls what operations are allowed at all. allow whitelists command patterns. danger marks capabilities that require explicit opt-in.
env defines execution-environment constraints as policy (provider defaults, provider allow/deny rules, tools/mcps/network allowlists). These constraints attenuate runtime box/env configs and cannot be bypassed by local config.
policy @p = {
env: {
default: "@provider/sandbox",
providers: {
"@provider/sandbox": { allowed: true },
"@provider/raw": { allowed: false }
},
tools: { allow: ["Read", "Write"] },
mcps: { allow: [] },
net: { allow: ["github.com"] }
}
}
Guards can also return policy fragments through env actions. The fragment is merged into active policy for that operation before environment config is derived:
guard before op:run = when [
* => env {
env: { tools: ["Read", "Write"] },
policy: { env: { tools: { allow: ["Read"] } } }
}
]
danger: ["@keychain"] is required for keychain sources declared in policy.auth. Standalone top-level auth declarations do not require danger.
needs declarations are module requirement checks. They do not replace capability policy rules.
authorizations declares which tool:w operations are authorized for a task, with per-argument constraints on control args. It compiles to internal privileged guards that enforce a default-deny envelope. In the current phase this applies only to tool:w, and trusted control-arg metadata comes from the active var tools collection via controlArgs. Use this for planner-authorized agent execution: the planner produces a JSON fragment containing authorizations, and the host injects it via with { policy }. Invalid authorization fragments fail closed during activation.
var @taskPolicy = {
authorizations: {
allow: {
send_email: { args: { recipients: ["mark@example.com"] } },
create_file: true
}
}
}
var @result = @worker(@prompt) with { policy: @taskPolicy }
See policy-authorizations for full syntax including control-arg enforcement and validation.
Export/import: Share policies across scripts:
export { @p }
>> In another file
import policy @p from "./policies.mld"
Policies compose with union() -- combine multiple config objects into one policy. The most restrictive rules win.
Policy Capabilities
The capabilities object controls what operations can run.
policy @p = {
capabilities: {
allow: ["cmd:git:*", "cmd:npm:*", "fs:r:**", "fs:w:@root/tmp/**"],
danger: ["@keychain", "fs:r:~/.ssh/*"],
deny: ["sh"]
}
}
run cmd { git status }
Tool restrictions:
| Pattern | Matches |
|---|---|
cmd:git:* |
git with any subcommands |
cmd:npm:install:* |
npm install with any args |
sh |
Shell access |
Command allow/deny patterns evaluate against the interpolated command text, including @var substitutions.
Filesystem patterns:
| Pattern | Access |
|---|---|
fs:r:** |
Read any path |
fs:w:@root/tmp/** |
Write under tmp (implies read) |
fs:r:~/.config/* |
Read home config files |
Flat syntax (shorthand):
policy @p = {
allow: ["cmd:echo:*", "fs:r:**"],
deny: { sh: true, network: true }
}
Both forms are equivalent. The nested form (capabilities: { ... }) is more explicit; the flat form places allow/deny at the top level as shorthand.
How allow and danger interact:
allow and danger are two independent gates. allow is the general whitelist: it controls whether an operation is permitted at all. danger is a separate opt-in gate for sensitive operations that mlld considers inherently risky — reading SSH keys, force-pushing, running sudo, accessing the keychain, and similar. Both gates must pass for an operation to proceed.
mlld ships with a built-in default danger list (defined in core/policy/danger.ts) covering credential files, destructive commands, and security-bypass flags. When an operation matches the default danger list, policy blocks it unless the policy's danger array explicitly includes a matching pattern. This check runs independently of allow — an operation that matches allow but falls on the danger list is still blocked.
policy @p = {
allow: ["cmd:git:*", "fs:r:**"],
deny: ["sh"]
}
>> allow matches cmd:git:* — but git push --force is on the
>> default danger list. Without danger: ["cmd:git:push:*:--force"],
>> this is blocked with "Dangerous capability requires allow.danger".
run cmd { git push origin main --force }
To unblock it, add the matching pattern to danger:
policy @p = {
allow: ["cmd:git:*", "fs:r:**"],
danger: ["cmd:git:push:*:--force"],
deny: ["sh"]
}
The same double-gate applies to filesystem access. allow: ["fs:r:**"] permits reading all files, but reading ~/.ssh/id_rsa still requires danger: ["fs:r:~/.ssh/*"] because that path matches the default danger list.
Danger list: Operations matching danger require explicit opt-in. Without danger: ["@keychain"], keychain access is blocked even if other rules allow it.
Keychain allow/deny patterns live under policy.keychain and match service/account paths (with {projectname} from mlld-config.json).
Common mistakes:
toolsin env config enforces runtime tool access (Bashfor shell commands, tool names for MCP calls)capabilities.denyhandles command-pattern policy rules (for examplecmd:git:push)- Keychain access requires both
danger: ["@keychain"]in capabilities ANDprojectnameinmlld-config.json no-secret-exfildoesn't blockshow/log— add label flow rules forop:showandop:log(seepolicy-auth)
See policy-auth for credential flow, box-config for environment restrictions.
Operation Risk Labels
Classify operations by risk using the two-step pattern: label exe functions with semantic labels describing WHAT they do, then map those to risk categories in policy.
>> Step 1: Semantic labels describe the operation
exe net:w @postToSlack(msg) = run cmd { slack-cli "@msg" }
exe fs:w @deleteFile(path) = run cmd { rm -rf "@path" }
>> Step 2: Policy groups semantic labels under risk categories
policy @p = {
defaults: { rules: ["no-secret-exfil", "no-untrusted-destructive"] },
operations: {
exfil: ["net:w"],
destructive: ["fs:w"]
}
}
Now secret data cannot flow to @postToSlack (exfil rule) and untrusted data cannot flow to @deleteFile (destructive rule).
Why two steps?
- Reusability: Many functions share the same semantic label (
net:wapplies to Slack, email, webhooks). Changing the risk classification ofnet:wupdates all of them at once. - Flexibility: The same exe definition works under different policies. A dev policy might allow
net:w; a production policy classifies it asexfil. - Composability: Semantic labels are stable across teams and libraries. Risk classifications are a policy decision, not a code decision.
Risk categories:
| Category | Meaning |
|---|---|
exfil |
Sends data outside the system |
destructive |
Deletes or modifies data irreversibly |
privileged |
Requires elevated permissions |
Risk labels can be hierarchical. exfil:send is a child of exfil, so no-secret-exfil still blocks secrets sent through it, while no-send-to-unknown can add a destination check on @input[0]. destructive:targeted is a child of destructive, so no-untrusted-destructive still applies while no-destroy-unknown adds a positive check that the target in @input[0] is known.
Multiple labels: Combine when an operation has multiple risks:
exe net:w, fs:w @exportAndDelete(data) = run cmd { backup_and_delete "@data" }
policy @p = {
operations: { exfil: ["net:w"], destructive: ["fs:w"] }
}
Alternative -- direct risk labeling: You can label exe functions directly with risk categories, skipping the mapping step:
exe exfil @sendToServer(data) = run cmd { curl -d "@data" https://api.example.com }
exe destructive @deleteFile(path) = run cmd { rm -rf "@path" }
This is simpler but couples exe definitions to risk categories. The two-step pattern is preferred for maintainability.
See policy-authorizations for how operations interact with per-tool authorization and control-arg enforcement.
Complete example:
policy @p = {
defaults: { rules: ["no-secret-exfil"] },
operations: { exfil: ["net:w"] }
}
var secret @patientRecords = <clinic/patients.csv>
exe net:w @post(data) = run cmd { curl -d "@data" https://api.example.com }
show @post(@patientRecords)
Error: Rule 'no-secret-exfil': label 'secret' cannot flow to 'exfil'
Policy Label Flow Rules
The labels block in policy defines which data labels can flow to which operations.
policy @p = {
labels: {
secret: {
deny: ["op:cmd", "op:show", "net:w"]
},
"src:mcp": {
deny: ["op:cmd:git:push", "op:cmd:git:reset", "destructive"],
allow: ["op:cmd:git:status", "op:cmd:git:log"]
}
}
}
Deny/allow targets are operation labels -- both auto-applied (op:cmd, op:show) and user-declared (net:w, destructive, safe).
Prefix matching: A deny on op:cmd:git blocks all git subcommands (op:cmd:git:push, op:cmd:git:reset, etc.).
Most-specific-wins: When deny covers a prefix but allow covers a more specific path, the specific rule wins. Given deny: ["op:cmd:git"] and allow: ["op:cmd:git:status"], git status is allowed but git push is blocked.
Label-flow policy evaluates declared labels and taint labels (src:*, dir:*) attached to values.
Built-in rules vs. explicit deny lists: For common protection patterns, use defaults.rules with built-in rules like no-secret-exfil instead of writing explicit deny lists. See policy-operations for the two-step classification pattern where semantic labels (e.g., net:w) are mapped to risk categories (e.g., exfil) via policy.operations.
Privileged guard overrides: Managed label-flow denials can be overridden by an explicit privileged guard allow for specific operations. This enables a pattern where policy sets a broad restriction and a privileged guard punches specific holes. To prevent this, use locked: true on the policy.
In composed policies: Label deny/allow rules from all composed policy layers merge via union. A deny on secret → op:cmd from ANY layer blocks that flow in the merged policy. See policy-composition for merge rules.
Complete denial example:
policy @p = {
labels: {
secret: { deny: ["op:show"] }
}
}
var secret @customerList = <internal/customers.csv>
show @customerList
Error: Label 'secret' cannot flow to 'op:show' -- the policy blocks secret-labeled data from reaching show.
For per-tool authorization with argument constraints (rather than label-flow rules), see policy-authorizations.
See labels-sensitivity for declaring labels, labels-source-auto for source label rules.
Policy Composition
Multiple policies compose automatically when imported or declared.
>> Team policy allows echo and git
/policy @p1 = { capabilities: { allow: ["cmd:echo:*", "cmd:git:*"] } }
>> Project policy allows echo and node
/policy @p2 = { capabilities: { allow: ["cmd:echo:*", "cmd:node:*"] } }
>> Effective: only echo (intersection of both policies)
/run { echo "allowed by both" }
Import pattern:
/import policy @baseline from "./baseline.mld"
/import policy @company from "./company.mld"
/policy @localPolicy = { deny: { sh: true } }
Composition rules:
| Field | Rule | Effect |
|---|---|---|
allow |
Intersection | Must be allowed by ALL policies |
deny |
Union | Denied by ANY policy |
danger |
Intersection | Must be opted into by ALL |
limits |
Minimum | Most restrictive wins |
Note: If allow lists have no overlap, the intersection is empty and all operations are blocked. Ensure shared baseline commands appear in all layers.
Profile selection considers composed policy. The first profile whose requires all pass is selected:
/policy @p = { deny: { sh: true } }
/profiles {
full: { requires: { sh } },
readonly: { requires: { } }
}
>> Selects "readonly" because sh is denied
/show @mx.profile
Label deny rules and auth configs from all layers merge via union — a deny on secret → op:cmd from ANY layer blocks that flow in the merged policy.
Authorizations merge with the same "most restrictive wins" principle. In the current phase this only governs tool:w operations:
| Field | Rule | Effect |
|---|---|---|
allow (operations) |
Intersection | Must be authorized by ALL layers |
args (constraints) |
Conjunction | All constraints from every layer must pass |
true merged with a constrained entry becomes the constrained entry (stricter wins). Incompatible constraints on the same arg remain valid config but no runtime value can satisfy them — the call is denied.
See security-policies for basic definition, policy-capabilities for capability syntax, policy-label-flow for label rules, policy-authorizations for authorization syntax.
Policy Auth
using auth:* injects credentials as environment variables using sealed paths.
Why sealed paths matter: injected credentials bypass string interpolation. They are set at process env level and do not pass through prompt-controlled template text.
auth @brave = "BRAVE_API_KEY"
policy @p = {
auth: {
claude: { from: "keychain", as: "ANTHROPIC_API_KEY" },
github: { from: "env:GH_TOKEN", as: "GH_TOKEN" },
brave: "BRAVE_API_KEY"
}
}
run cmd { claude -p "hello" } using auth:claude with { policy: @p }
Standalone auth and policy.auth use the same mapping shape. Use policy.auth when callers need to remap module auth names.
Config forms
| Field | Purpose |
|---|---|
from |
Source: "keychain:path", "keychain", or "env:VAR" |
as |
Target environment variable name |
Short form examples:
auth @brave = "BRAVE_API_KEY"
policy @p = {
auth: {
brave: "BRAVE_API_KEY",
claude: { from: "keychain", as: "ANTHROPIC_API_KEY" }
}
}
Expansion rules:
"BRAVE_API_KEY"->{ from: "keychain:mlld-box-{projectname}/BRAVE_API_KEY", as: "BRAVE_API_KEY" }{ from: "keychain", as: "ANTHROPIC_API_KEY" }->{ from: "keychain:mlld-box-{projectname}/ANTHROPIC_API_KEY", as: "ANTHROPIC_API_KEY" }
Resolution order
For using auth:name, mlld resolves in this order:
- Auth captured on the executable where it was defined
- Caller
policy.auth - Caller standalone
auth
Caller bindings override same-name captured bindings.
Keychain behavior
Keychain paths use service/account and support {projectname} from mlld-config.json.
Resolution for from: "keychain:...":
- Read keychain entry
- If missing, read
process.env[as] - If both missing, throw
Unsupported provider schemes (for example op://...) fail with an explicit error.
policy.keychain.allow and policy.keychain.deny still gate keychain access.
danger: ["@keychain"] is required for policy.auth keychain sources. Standalone auth declares keychain intent directly and does not require danger.
Linux keychain access uses secret-tool (libsecret). Ensure secret-tool is on PATH.
policy @p = {
auth: {
claude: { from: "keychain:mlld-box-{projectname}/claude", as: "ANTHROPIC_API_KEY" }
},
keychain: {
allow: ["mlld-box-{projectname}/*"],
deny: ["system/*"]
},
capabilities: { danger: ["@keychain"] }
}
run cmd { claude -p "hello" } using auth:claude with { policy: @p }
Label flow checks for using auth:*
Auth injection keeps secrets out of command strings, but policy label flow checks still apply to env injection. Secrets injected via using auth:* are treated as secret input for policy checks, and using @var as ENV uses the variable's labels.
policy @p = {
auth: { api: { from: "env:SECRET", as: "API_KEY" } },
labels: { secret: { deny: ["exfil"] } }
}
>> BLOCKED: secret flows to exfil-labeled operation
exe exfil @send() = run cmd { curl -H "Auth: $API_KEY" ... } using auth:api
show @send()
Explicit variable injection
var secret @token = "computed-value"
run cmd { tool } using @token as TOOL_KEY
Direct keychain access in templates/commands is blocked; use auth or policy.auth with using auth:* instead.
Note: no-secret-exfil blocks secrets flowing through exfil-labeled operations. To also block direct show or log of secrets, add label flow rules:
policy @p = {
labels: { secret: { deny: ["op:show", "op:log"] } }
}
Box
Boxes are mlld's primitive for scoped execution contexts. They encapsulate credentials, isolation, capabilities, and state.
var @sandbox = {
provider: "@mlld/env-docker", >> Docker container for process isolation
fs: { read: [".:/app"], write: ["/tmp"] }, >> Mount host . as /app, allow writes to /tmp
net: "none" >> No network access
}
box @sandbox [
run cmd { npm test }
]
Why boxes matter for security:
- Credential isolation - Auth injected via sealed paths, not exposed as strings
- Capability restriction - Limit what tools and operations agents can use
- Blast radius - Contain failures within box boundaries
Boxes are values:
var @task = "Review code"
var @cfg = { auth: "claude", tools: ["Read", "Write"] }
var @readonly = { ...@cfg, tools: ["Read"] }
box @readonly [ run cmd { claude -p @task } ]
Compute, compose, and pass boxes like any other value.
Use object spread for plain object derivation. The with { ... } clause is box-directive config syntax (for box @cfg with { ... } [ ... ]).
For enforcement boundaries (what mlld enforces locally vs what requires a sandbox provider), see the table in box-config.
Providers add isolation:
| Provider | Isolation | Use Case |
|---|---|---|
| (none) | Local execution | Dev with specific auth |
@mlld/env-docker |
Container | Process isolation |
@mlld/env-sprites |
Cloud sandbox | Full isolation + state |
Without a provider, commands run locally with specified credentials.
Complete sandbox example:
Combine box config with policy to restrict an agent:
policy @p = {
capabilities: {
allow: ["cmd:claude:*"], >> Only allow claude commands
deny: ["sh"] >> Block shell access
}
}
var @sandbox = {
tools: ["Read", "Write", "Bash", "Glob", "Grep"], >> Allow tools for agent use
mcps: [] >> Block MCP servers in this block
}
box @sandbox [
run cmd { claude -p "Analyze code" }
]
For a complete working example with Docker isolation, credentials, and guards, see sandbox-demo in llm/run/j2bd/security/impl/sandbox-demo.mld.
Reading order: box-config for configuration fields, box-blocks for scoped execution, policy-capabilities for restrictions, policy-auth for credentials.
Box Directive
The box directive creates scoped execution contexts that combine process isolation, credential management, and capability control.
For concepts and configuration details, see box-overview, box-config, and box-blocks.
Sandboxed execution with credentials:
var @sandbox = {
provider: "@mlld/env-docker",
fs: { read: [".:/app"], write: ["/tmp"] },
net: "none",
tools: ["Read", "Bash"],
mcps: []
}
box @sandbox [
run cmd { claude -p "Analyze the codebase" } using auth:claude
]
The provider runs commands in a Docker container. fs restricts filesystem mounts, net blocks network access, tools limits runtime tool availability, and mcps: [] blocks MCP servers. Credentials flow through sealed paths via using auth:* — never interpolated into command strings.
Local execution with different auth:
var @cfg = { auth: "claude-alt" }
box @cfg [
run cmd { claude -p @task } using auth:claude-alt
]
Without a provider, commands run locally. Use this for credential rotation across calls (e.g., multiple API keys to avoid per-account rate limits).
Config fields:
| Field | Purpose |
|---|---|
provider |
Isolation provider ("@mlld/env-docker", "@mlld/env-sprites") |
auth |
Authentication reference from policy |
tools |
Runtime tool allowlist |
mcps |
MCP server allowlist ([] blocks all) |
fs |
Filesystem access (passed to provider) |
net |
Network restrictions (passed to provider) |
limits |
Resource limits (passed to provider) |
profile |
Explicit profile selection |
profiles |
Profile definitions for policy-based selection |
Capability attenuation with with:
var @sandbox = {
provider: "@mlld/env-docker",
tools: ["Read", "Write", "Bash"]
}
box @sandbox with { tools: ["Read"] } [
>> Only Read is available here
run cmd { claude -p @task }
]
with derives a restricted child inline. Children can only narrow parent capabilities, never extend them.
VFS resolver shorthand:
files <@workspace/> = [{ "task.md": "checklist" }]
box @workspace [
run cmd { cat task.md }
]
box @workspace is shorthand for box { fs: @workspace } [...].
Anonymous VFS box:
box [
file "task.md" = "inside box"
run cmd { cat task.md }
]
All box forms provide an in-memory workspace. box { tools: ["Read", "Bash"] } [ ... ] restricts tools while still using workspace VFS. Use box { fs: @workspace } [ ... ] to bind an existing resolver-backed filesystem instead.
Tool scope formats:
box @config with { tools: ["read", "write"] } [...]
box @config with { tools: "read, write" } [...]
box @config with { tools: "*" } [...]
var @subset = { read: @readTool, write: @writeTool }
box @config with { tools: @subset } [...]
Profile selection:
var @cfg = {
profiles: {
full: { requires: { sh: true } },
readonly: { requires: {} }
}
}
box @cfg with { profile: "readonly" } [
run cmd { claude -p @task }
]
When no profile is specified, the first profile whose requirements are satisfied by the active policy is selected.
Return values:
var @result = box @config [
let @data = run cmd { fetch-data }
=> @data
]
Scoped environment:
The box block creates a child environment. Variables defined inside don't leak out, but the block can access parent scope variables.
var @input = "test"
box @config [
let @processed = @input | @transform
=> @processed
]
Box Configuration
Box configuration objects control isolation, credentials, and resource limits.
var @sandbox = {
provider: "@mlld/env-docker",
fs: { read: [".:/app"], write: ["/tmp"] },
net: "none",
limits: { mem: "512m", cpu: 1.0, timeout: 30000 }
}
box @sandbox [
run cmd { npm test }
]
VFS-backed local mode:
files <@workspace/> = [{ "task.md": "todo" }]
box @workspace [
run cmd { cat @root/task.md }
]
These forms create a VirtualFS-backed runtime for local execution:
box @workspace [ ... ]box { fs: @workspace } [ ... ]box [ ... ](anonymous workspace)
In VFS mode, defaults are applied unless overridden:
tools: ["Bash", "Read", "Write", "Glob", "Grep"]mcps: []net: { allow: [] }
VFS defaults apply when the box has a workspace (anonymous box [...], box @workspace [...], or box { fs: @workspace } [...]) and no explicit tools field is provided. Explicit with { tools: [...] } overrides the defaults. Active policy.env constraints are then applied to the resolved box config — local box config can only be attenuated by policy (for example, requested tools are intersected with policy.env.tools.allow, and denied providers throw).
Configuration fields:
| Field | Values | Purpose |
|---|---|---|
provider |
"@mlld/env-docker", etc. |
Isolation provider |
fs |
{ read: [...], write: [...] } |
Filesystem access |
net |
"none", "host", "limited" |
Network restrictions |
limits |
{ mem, cpu, timeout } |
Resource limits |
auth |
"credential-name" |
Auth reference from policy |
tools |
["Read", "Write", "Bash", "Glob", "Grep"] |
Runtime tool allowlist for commands and MCP tools |
mcps |
[], [server-config] |
Runtime MCP server allowlist |
Important: tools and mcps enforce runtime access inside box blocks.
| Field | Enforced locally by mlld? | Notes |
|---|---|---|
tools |
Yes | mlld restricts available tools |
mcps |
Yes | mlld restricts available MCP servers |
fs |
No - requires container provider | mlld passes config but cannot enforce filesystem restrictions without a sandbox |
net |
No - requires container provider | mlld passes config but cannot enforce network restrictions without a sandbox |
limits |
No - requires container provider | mlld passes config but cannot enforce resource limits without a sandbox |
- Include
Bashintoolsto allowrun cmd,run sh, and shell-backed command executables. - Set
mcps: []to block all MCP tool calls, or list servers to allow specific MCP sources. - Use
capabilities.denyfor command-pattern policy rules (for examplecmd:git:push).
Advanced: MCP configuration via @mcpConfig():
Define an @mcpConfig() function to provide profile-based MCP server configuration:
var @cfg = {
profiles: {
full: { requires: { sh: true } },
readonly: { requires: {} }
}
}
exe @mcpConfig() = when [
@mx.profile == "full" => {
servers: [{ command: "mcp-server", tools: "*" }]
}
@mx.profile == "readonly" => {
servers: [{ command: "mcp-server", tools: ["list", "get"] }]
}
* => { servers: [] }
]
box @cfg with { profile: "readonly" } [
show @list()
]
The function is called when a box block spawns, with @mx.profile set from the with { profile } clause. When no profile is specified, the first profile whose requirements are satisfied by the active policy is selected. Explicit with { profile: "name" } overrides this automatic selection.
Compose with object spread:
var @readonly = {
...@sandbox,
fs: { ...@sandbox.fs, read: [".:/app"], write: [] }
}
See box-overview for concepts, box-directive for block syntax.
Box Blocks
Execute directives within a scoped environment using box @config [ ... ].
var @sandbox = { tools: ["Read", "Write", "Bash"] }
box @sandbox [
run cmd { echo "inside sandbox" }
]
The environment is active only within the block and released on exit.
Anonymous workspace block:
box [
file "task.md" = "inside box"
run cmd { cat task.md }
]
All box blocks create an in-memory workspace for the block. box [ ... ], box @config [ ... ], and box @workspace [ ... ] all support file/files directives and shell commands via ShellSession with cwd at the project root.
Named workspace block:
files <@workspace/> = [{ "task.md": "from resolver" }]
box @workspace [
run cmd { cat task.md }
]
Use box @workspace to bind an existing resolver-backed workspace as the active box filesystem.
Inspect workspace edits and diffs:
files <@workspace/> = [{ "task.md": "draft" }]
box @workspace [
file "notes.md" = "review complete"
]
show @workspace.mx.edits
show <@workspace/notes.md>.mx.diff
@workspace.mx.edits— array of{path, type, entity}where type iscreated/modified/deleted<@workspace/path>.mx.diff— unified diff string for a single file
Git hydration with files = git:
files <@workspace/> = git "https://github.com/mlld-lang/mlld" branch:"main" path:"docs/" depth:1
box @workspace with { tools: ["Read", "Bash"], net: { allow: ["github.com"] } } [
run cmd { ls . }
]
Use git as a files source to clone text files into workspace VFS.
Supported git options:
auth:token or keychain ref (for private repos)branch:branch/tag/commitpath:subdirectory within the repodepth:shallow clone depth (default1)
Hydration behavior:
- Text files are imported into workspace VFS
- Binary files and symlinks are skipped with warnings
- Imported files are tainted with
src:gitprovenance metadata box.netallow rules are enforced for remote git hosts
Return values and workspace binding:
var @result = box [
file "data.txt" = "hello"
=> "completed"
]
show @result >> "completed" — box returned a value via =>
When a box uses =>, the variable gets the returned value, not the workspace.
To access workspace files after the box exits, omit => so the variable binds to the workspace:
var @ws = box [
file "data.txt" = "hello"
]
show <@ws/data.txt> >> "hello" — reads from workspace via resolver
The <@name/path> resolver syntax reads from the workspace VFS after the box exits. Inside the box body, use run cmd { cat file.txt } to read via the ShellSession — bare <file.txt> reads from the real filesystem, not the active workspace.
Inline derivation with with:
var @sandbox = { tools: ["Read", "Write", "Bash"] }
var @result = box @sandbox with { tools: ["Read"] } [
=> "read-only mode"
]
show @result
Derives a restricted environment inline without naming it.
Named child environments:
var @sandbox = { tools: ["Read", "Write", "Bash"] }
var @readOnly = { ...@sandbox, tools: ["Read"] }
box @readOnly [
run cmd { cat README.md }
]
Child environments can only restrict parent capabilities, never extend them.
Notes:
- Directives inside blocks use bare syntax (no
/prefix) - Environment resources are released when the block exits
with { ... }is box directive config syntax (box @cfg with { ... } [ ... ]), not a general object-modifier expression- Ambient
@mx.boxincludes active bridge metadata (mcpConfigPath,socketPath) while inside the box scope - See
box-overviewfor concepts,box-configfor configuration fields
Per-call tool configuration via config.tools:
When an exe llm is invoked with a config.tools array, the runtime automatically creates MCP bridges and exposes the result on @mx.llm:
exe llm @agent(prompt, config) = [
let @cfg = @config ? @config : {}
>> @mx.llm.config — MCP config file path (empty string if no bridges)
>> @mx.llm.allowed — unified tool names for --allowedTools or equivalent
>> @mx.llm.native — native tool names CSV (empty when no native tools requested)
>> @mx.llm.inBox — true when an active VFS bridge exists
>> @mx.llm.hasTools — true when config.tools was specified
=> @prompt | cmd { claude -p --allowedTools "@mx.llm.allowed" }
]
Inside a box, string tools (like "Read") route through a filtered VFS bridge proxy. Outside a box, string tools pass through as native CLI tool names. Exe refs always get their own function MCP bridge. The runtime handles all of this — module authors just read @mx.llm.
Function tools (exe refs) get their own MCP server:
exe @double(n) = cmd { echo $(( @n * 2 )) }
var @r = box [
let @answer = @claude("Double 21", { model: "haiku", tools: ["Read", @double] })
=> @answer
]
The runtime combines both the VFS bridge (for Read) and a function bridge (for @double) into a single MCP config file, exposed via @mx.llm.config.
Auth
Use auth to declare credentials at module scope without requiring callers to import policy objects.
Standalone auth
auth @brave = "BRAVE_API_KEY"
exe @search(q) = js { /* uses process.env.BRAVE_API_KEY */ } using auth:brave
Short form expands to:
from: "keychain:mlld-box-{projectname}/BRAVE_API_KEY"as: "BRAVE_API_KEY"- runtime resolution: keychain first, then
process.env.BRAVE_API_KEY
Long forms
auth @brave = { from: "keychain", as: "BRAVE_API_KEY" }
auth @brave = { from: "keychain:custom-service/custom-account", as: "BRAVE_API_KEY" }
auth @brave = { from: "env:SOME_OTHER_VAR", as: "BRAVE_API_KEY" }
from: "keychain" expands to keychain:mlld-box-{projectname}/<as>.
Unknown provider schemes (for example op://...) fail with a clear error until provider support is added.
Policy composition
policy.auth still works and accepts the same short/long forms:
policy @p = {
auth: {
brave: "BRAVE_API_KEY",
claude: { from: "keychain", as: "ANTHROPIC_API_KEY" }
}
}
Resolution order for using auth:name:
- Auth captured on the executable where it was defined
- Caller
policy.auth - Caller standalone
auth
Caller definitions override same-name module auth.
Keychain CLI
mlld keychain add BRAVE_API_KEY
mlld keychain get BRAVE_API_KEY
mlld keychain list
mlld keychain rm BRAVE_API_KEY
mlld keychain import .env
Entries are stored as service=mlld-box-{projectname} / account=<name>.
07b Policy
Policy Authorizations
The authorizations section in policy declares which tool:w operations are authorized for a task, with per-argument constraints on control args. In the current phase it applies only to tool:w. The runtime compiles these into internal privileged guards that enforce a default-deny envelope.
policy @base = {
defaults: { rules: ["no-send-to-unknown", "no-destroy-unknown"] },
operations: {
"exfil:send": ["tool:w:send_email", "tool:w:share_file"],
"destructive:targeted": ["tool:w:delete_file"]
}
}
var @taskPolicy = {
authorizations: {
allow: {
send_email: {
args: {
recipients: ["mark@example.com"]
}
},
create_file: true
}
}
}
var @result = @worker(@prompt) with { policy: @taskPolicy }
The with { policy } merge combines @taskPolicy with the ambient @base policy. The merged config activates authorization enforcement for the call chain.
Tool Metadata
Phase 1 reads trusted control-arg metadata from the active tool collection. Declare write-tool labels and control args on var tools entries:
var tools @agentTools = {
send_email: {
mlld: @send_email,
labels: ["tool:w:send_email"],
expose: ["recipients", "cc", "bcc", "subject"],
controlArgs: ["recipients", "cc", "bcc"]
},
create_file: {
mlld: @create_file,
labels: ["tool:w:create_file"],
expose: ["title"],
controlArgs: []
}
}
controlArgs must reference visible tool parameters. mlld validate --context tools.mld and runtime activation both use this trusted metadata when checking policy.authorizations.
Entries
Keys under authorizations.allow are exact operation names matching @mx.op.name. For MCP-backed tools, use the mlld-side canonical name, not the provider's raw tool name.
| Form | Meaning |
|---|---|
| Omitted (but in scope) | Denied. Default-deny for unlisted tool:w operations. |
create_file: true |
Authorized with no argument constraints. Only valid for tools with no declared control args. |
send_email: { args: { ... } } |
Authorized. Listed args must satisfy constraints. |
{} and { args: {} } are accepted but normalized to true with a warning. The canonical form for unconstrained authorization is true.
Argument Constraints
Each constrained argument accepts:
Literal value — uses tolerant comparison (~=):
var @taskPolicy = {
authorizations: {
allow: {
send_email: {
args: {
recipients: ["mark@example.com"]
}
}
}
}
}
Explicit eq — equivalent to bare literal, for clarity:
send_email: {
args: {
recipients: { eq: ["mark@example.com"] }
}
}
One-of — arg must match any candidate:
send_email: {
args: {
recipients: { oneOf: [["mark@example.com"], ["sarah@example.com"]] }
}
}
Tolerant comparison (~=) handles string-vs-array, ordering, null equivalence, and subset matching. The worker can do less than authorized (fewer recipients) but not more (additional unauthorized recipients).
Control-Arg Enforcement
Tools declare which arguments are security-relevant (control args) on the trusted tool collection entry via controlArgs. The runtime consumes this metadata to enforce that planners constrain all control args.
Two enforcement layers:
Validation (with tool context): mlld validate --context tools.mld catches missing constraints before execution:
- A declared control arg that is NOT constrained in the
authorizationsentry is a validation error. The planner must pin it with a literal,eq, oroneOfconstraint. - A tool with declared control args authorized as
true(unconstrained) is a validation error.trueis only valid for tools with no declared control args.
Runtime (always): Whether or not validation ran, the runtime enforces that args not mentioned in the constraint must be empty/null. If the planner doesn't mention cc on send_email, the runtime enforces that cc must be null, [], or absent. This prevents silent omission from becoming an open hole.
- Arguments not declared as control args are unconstrained data args — the worker fills them freely.
Example: send_email declares recipients, cc, bcc as control args. subject, body, attachments are data args.
{
"authorizations": {
"allow": {
"send_email": {
"args": {
"recipients": ["mark@example.com"]
}
}
}
}
}
This authorizes send_email with recipients pinned to mark@example.com. Because cc and bcc are declared control args but omitted from the constraint, they are enforced as empty/null at runtime. The planner doesn't need to mention subject or body — those are data args.
If the planner had written "send_email": true, validation would reject it because send_email has declared control args.
Enforcement
authorizations compiles to internal privileged guards. These are the same guards that defaults.rules and labels produce — they participate in the standard guard override mechanism:
- Matching
allowcan override managed label-flow denials fromdefaults.rulesandlabels(unlesslocked: true) locked: truedisables all overrides — authorization entries are still checked, but a matching entry cannot punch through locked denials- Capability denials (
capabilities.allow/deny/danger),envrestrictions,auth, andlimitsare separate enforcement paths and are not affected byauthorizations
Authorization denials behave like any other guard denial — they can be caught with denied => handlers and are surfaced through the SDK's existing denial reporting.
Planner Use
The primary use case is planner-authorized agent execution. A planning LLM produces a JSON authorization fragment. The step script parses it and injects it via with { policy }:
var @plannerOutput = @planner(@task) | @parse
var @result = @agent(@prompt) with { policy: @plannerOutput }
The planner's output should contain only authorizations — not defaults, rules, locked, labels, operations, or other policy sections. Those are developer-controlled. The runtime treats with { policy } as a generic policy merge path, so the host is responsible for restricting planner output before injection.
Validation
mlld validate --context tools.mld checks authorizations fragments:
- Every
authorizations.allowkey must resolve to a known exe in context - Every constrained arg name must exist on that exe's parameter list
- A declared control arg omitted from the
argsconstraint is an error - A tool with declared control args authorized as
trueis an error {}and{ args: {} }produce normalization warnings
Invalid fragments fail closed: if validation fails, the policy is not activated, the exe call fails with a structured error, and the host decides recovery.
Composition
When multiple active policy layers have authorizations, they compose via the standard "most restrictive wins" rule:
| Aspect | Rule |
|---|---|
| Allowed operations | Intersection (both must authorize) |
| Constraints per operation | Conjunction (all must pass) |
true merged with a constrained entry becomes the constrained entry. Incompatible constraints (e.g., eq: "a" and eq: "b") are valid config but no runtime value can satisfy them — the call is denied.
See policy-composition for general merge rules. See guards-privileged for the override mechanism.