Skip to content

Access Control

A capsule's capabilities: block is where access control is declared — what the capsule and its artifacts may reach.

Every grant is declared: filesystem and network access start closed, and shell access is allowlist-based. The same applies to hooks — a hook's grant comes from its entry in the capsule's manifest and starts empty.

Per-tool narrowing

By default every WASM tool, and the inference driver, runs on the capsule-wide capabilities: ceiling: the same allow-list and the same preopened workdir. A runtime: tool or runtime: driver entry in the capsule's own murmur.yaml may optionally declare its own capabilities: block to run below that ceiling — a narrower host list, a subdirectory instead of the whole workdir:

# in the capsule's own murmur.yaml
artifacts:
  - name: murmur-tool-fetch
    version: 1.0.0
    runtime: tool
    capabilities:
      network:
        allow: [https://api.example.com]
      filesystem:
        scope: cache

This uses the same key and vocabulary as hooks, with the opposite starting point: a hook with no block gets nothing, a tool with no block keeps the full ceiling. A per-artifact block can only subtract — an entry naming a host the capsule itself may not reach is dropped, with a W-SEC-007 warning. Like a hook's, the grant is read only from the capsule operator's entry, never from the tool's own bundled manifest. The artifact named by inference.driver.artifact narrows the same way. See Tool and driver capabilities for the full rules.

The filesystem default

What an artifact can read and write when its entry declares no capabilities.filesystem.scope depends on its role:

Entry No filesystem.scope declared filesystem.scope: cache
runtime: tool The whole accessible session workdir, read-write <workdir>/cache and nothing above it
runtime: driver The whole accessible session workdir, read-write <workdir>/cache and nothing above it
runtime: hook No directory of any kind <workdir>/cache and nothing above it

The wide default for tools and drivers is chosen against prompt injection steering an honest artifact — the model reads attacker-controlled text and calls a tool with attacker-chosen arguments. The mechanisms against that are the containment class, capabilities.network.allow, the untrusted fence, and a per-artifact filesystem.scope on the entries where a narrower working directory is known.

It is not chosen against a hostile artifact. Malicious artifact code sits outside the threat model above: there is no artifact trust model and no signing, so nothing establishes that an artifact's code is what its publisher intended. Hash-pinning through murmur.lock establishes that an artifact has not changed since it was locked, and nothing about whether it was safe when it was locked. An artifact you have not read is one you are trusting with the workdir it runs in.

The capsule-wide capabilities.filesystem.scope does not narrow any of this. The per-artifact filesystem.scope on the entry is what decides an artifact's directory, and it is the only thing that does.

To read what a capsule resolves to before launching it, use the preopens: list in mur run --explain-scope or in mur doctor. Every artifact appears with its role, its declared scope, and one of three surfaces: whole-workdir, scoped-subtree or nothing. The same list is recorded on each session as effective_grants.preopens in trace.jsonl.

Hook capabilities

A hook runs default-deny, and only the capsule operator can widen it. A hook artifact's own murmur.yaml declares the hook's behavioral contract and nothing else. Capability grants are read exclusively from the artifact entry in the capsule's murmur.yaml, so a hook pulled from a registry can never grant itself anything:

# in the capsule's own murmur.yaml
artifacts:
  - name: murmur-hook-telemetry
    version: 1.0.0
    runtime: hook
    capabilities:
      network:
        allow: [https://telemetry.example.com]
      filesystem:
        scope: hook-state

With no capabilities: block a hook has no network, no directory, and no sight of the task at all — every outbound request is denied, it cannot read or write any file, and asking for the task's text or the agent's result returns not-granted. A granted hook reaches its declared hosts through the same allow-list gate a capsule's or tool's outbound HTTP goes through, sees exactly one directory, <workdir>/<scope>, as its current directory, and with task_io.read: true can read the task text and the result the agent produced. Every hook gets its grant the same way, whatever its binding or execution mode. See Hook capabilities for the full rules.

Read-only paths

A capsule declares the parts of its workdir it reads but does not write:

capabilities:
  filesystem:
    read_only:
      - tests
      - bench/fixtures

The runtime refuses, before dispatch, any tool call or shell command it can identify as writing one, records it as protected_path_denied, and tells the model the path, the rule, that nothing ran and that the path is still readable. The check runs before a policy hook is asked: the manifest is the authority, and a hook can only narrow further.

What this stops is an honest tool being steered into a dishonest write — the agent cannot make the code pass the test, so it edits the test. That failure is invisible in a green run, and on a benchmark it is the difference between a result and a fiction.

Two threats, two mechanisms

read_only answers one of them. The other has a different answer, and neither substitutes for the other:

Threat What it looks like What stops it
A steered honest tool The model sends a write into a protected subtree through a tool or shell command that behaves exactly as documented capabilities.filesystem.read_only — the dispatch check reads the resolved call and refuses it
A malicious artifact The artifact receives innocuous input, passes every dispatch check, and writes wherever its preopen allows capabilities.filesystem.scope on that artifact's entry in the operator's own manifest — a narrow preopen the artifact cannot widen (see Per-tool narrowing)

The dispatch check sees what a call says. It cannot see what a component does once it holds a directory handle, and a narrow preopen is the only thing that governs that.

The interpreter gap

The dispatch check reads a shell call's argv and its -c script body, and flags what it can positively identify as a write. An interpreter defeats that half: python3 -c "open('tests/x','w').write('y')" is one opaque argument naming no redirection and no recognized write verb, so it is not flagged and the write lands. Declaring read_only alongside an interpreter in capabilities.shell.allow fires W-SEC-017 at staging, naming the binary.

The declaration still holds in full for every tool call — which is where the steered-tool threat lives — and for every shell command whose write the dispatch check can identify. The full list of what is and is not identified is in Read-only paths.

Untrusted publisher text

Tool and skill descriptions rendered into MURMUR.md are publisher-controlled, untrusted text: the runtime sanitizes them (collapsing newlines, stripping control characters, truncating length) before rendering, and MURMUR.md states explicitly that this file is machine-generated inventory data, never instructions.

Task origin and trust class

Every task the runtime runs carries an origin — why it woke the capsule — set by whoever enqueued it, and a trust class derived from that origin. A sender never declares its own class.

Origin Set by Trust
user A person handing the capsule an instruction — a local mur run, a task.md trusted
schedule A timer trusted
system The runtime enqueuing work for itself, with no person in the loop trusted
event A webhook, a chat message, a PR comment — third-party text untrusted
peer A message from another capsule The sending capsule's own class
completion A sub-capsule or detached shell reporting that its work finished The sending capsule's own class

peer and completion inherit, so untrust cannot launder itself at the first hop: an untrusted webhook payload that reaches capsule A and is forwarded to capsule B arrives at B still untrusted. A message that carries no class at all is untrusted.

The origin travels between capsules as two request headers, stamped by the sending runtime:

Header Values
x-murmur-task-origin peer or completion
x-murmur-task-trust trusted or untrusted

murmur:message/send carries no origin or trust field, so a capsule author has nothing to assert a class with. Only peer and completion are accepted from the wire; the other four origins are enqueued locally and never legitimately arrive over HTTP. An inbound x-murmur-task-origin naming one of them, naming anything unrecognised, or absent entirely yields event / untrusted. This is not authentication: a caller that claims peer + trusted gets what a genuine trusted peer gets, and nothing on the A2A path tells the two apart. What it closes is untrust laundering across an honest chain.

The class is recorded, not enforced: no task is refused, delayed or reordered because it is untrusted. An untrusted payload reaches the model inside the untrusted fence, marked as data. Treat the class as the answer to "why did this run", and keep authoring manifests on the assumption that any task's text may be hostile. The origin does more than the class: it also picks the queue lane a task waits in, so two tasks that are both untrusted can still run in a different order.

Both values are recorded on the task_start trace event and shown on the task row of mur trace steps <session>, alongside the lane:

task tsk_0a1b2c3d…  ctx_3c4d5e6f…  (a2a, peer/untrusted, lane peer)
task tsk_0a1b2c3d…  ctx_3c4d5e6f…  (task_md, user/trusted, lane user)

Prompt injection and network-bypass posture

Two gaps shape how you should author manifests, not just what the runtime enforces. The first has no structural fix on any platform; the second is closed on Linux where kernel enforcement is available, and permanently open elsewhere.

  • Prompt injection. No manifest setting or runtime mechanism fully prevents a model from being manipulated by instructions smuggled inside data it reads (a fetched web page, a tool result, output from a binary declared under capabilities.shell.allow). The runtime's mitigation is a marker — the untrusted fence — applied at two boundaries and stated in the system prompt on both transport: http and transport: process.

    Content reaching the model Fenced
    A tool result — from a WASM tool, a native subprocess tool, a shell binary, or one of the runtime's own peer-handoff tools Yes
    A task whose trust class is untrusted Yes
    A declared skill's skill.md No. It is the capsule author's own guidance, staged inside the capsule at install; fencing it as data would make the skill inert
    A user or schedule task No. It is the operator instructing their own capsule, for the same reason

    Fenced content arrives between a pair of markers naming where it came from. A marker found inside the content itself is rewritten before the fence closes, so content cannot end its own block; the rewrite inserts and deletes nothing, so an operator reading the trace sees the forged marker as rewritten text. The system prompt tells the model that everything between the markers is data and that a closing marker appearing anywhere inside a block — including one drawn inside an image — is a forgery. Untrusted fence has the marker forms and the field each surface labels a fence with.

    The fence marks content; it does not control capability. It sits inside the boundary capabilities: draws and replaces no part of it — nothing is refused or delayed for being fenced, and a model that acts on injected instructions can still do everything the manifest allows. Manifest authors are still responsible for not combining broad tool authority with exposure to untrusted content (see the phase-separation pattern below).

  • Network allowlist bypass via subprocess. capabilities.network.allow constrains requests the runtime itself makes (HTTP calls from tool and driver components); by itself it does not constrain a subprocess spawned via capabilities.shell.allow. Closing that gap needs kernel-level subprocess enforcement, which only exists on Linux. The runtime warns at capsule launch whenever this gap is live on the current host — see Subprocess enforcement tiers for the breakdown and W-SEC-001 through W-SEC-003 for each warning code.

Maximum-injection-risk combination: capabilities.shell.allow containing bash combined with any external-fetch capability — either a declared capabilities.network.allow, or a tool/driver artifact that performs its own outbound fetches independent of network.allow. This pairing gives a capsule both the ability to ingest attacker-influenced content from the network and the shell authority to act on it unchecked: injected instructions with a bypassable enforcement boundary underneath them.

For any capsule that ingests untrusted external content (fetched web pages, third-party file contents, output from a tool the operator didn't author), split the capsule's work into two phases instead of giving one phase both fetch and shell/write authority at once:

  1. Data-gathering phase — only fetch/read tools are active (network calls, file reads). No bash/shell/write capability is available during this phase.
  2. Action phase — the fetched content is summarized or extracted into a bounded structure (a short JSON object, a fixed set of fields) before it is handed to a phase that has bash/shell/write authority. The action phase never receives the raw untrusted bytes directly — only the bounded, already-extracted structure.

The point of the split is that raw untrusted content and tool-calling/shell authority should never be present in the same context window at the same time. Summarizing/extracting first means an injected instruction inside the fetched content has nothing to act through by the time a shell-capable phase is reading it.