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:

  • tools in env config enforces runtime tool access (Bash for shell commands, tool names for MCP calls)
  • capabilities.deny handles command-pattern policy rules (for example cmd:git:push)
  • Keychain access requires both danger: ["@keychain"] in capabilities AND projectname in mlld-config.json
  • no-secret-exfil doesn't block show/log — add label flow rules for op:show and op:log (see policy-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:w applies to Slack, email, webhooks). Changing the risk classification of net:w updates 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 as exfil.
  • 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:

  1. Auth captured on the executable where it was defined
  2. Caller policy.auth
  3. 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:...":

  1. Read keychain entry
  2. If missing, read process.env[as]
  3. 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 Bash in tools to allow run 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.deny for command-pattern policy rules (for example cmd: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 is created/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/commit
  • path: subdirectory within the repo
  • depth: shallow clone depth (default 1)

Hydration behavior:

  • Text files are imported into workspace VFS
  • Binary files and symlinks are skipped with warnings
  • Imported files are tainted with src:git provenance metadata
  • box.net allow 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.box includes active bridge metadata (mcpConfigPath, socketPath) while inside the box scope
  • See box-overview for concepts, box-config for 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:

  1. Auth captured on the executable where it was defined
  2. Caller policy.auth
  3. 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 authorizations entry is a validation error. The planner must pin it with a literal, eq, or oneOf constraint.
  • A tool with declared control args authorized as true (unconstrained) is a validation error. true is 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 allow can override managed label-flow denials from defaults.rules and labels (unless locked: true)
  • locked: true disables all overrides — authorization entries are still checked, but a matching entry cannot punch through locked denials
  • Capability denials (capabilities.allow/deny/danger), env restrictions, auth, and limits are separate enforcement paths and are not affected by authorizations

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.allow key 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 args constraint is an error
  • A tool with declared control args authorized as true is 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.