---
title: "Access Control"
description: "A capsule's capabilities: block is where access control is declared — what the capsule and its artifacts may reach."
canonical_url: "https://docs.murmur.nexus/concepts/access-control"
last_updated: "2026-09-16T11:27:08.000Z"
---

# 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:

```yaml
# 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`](/reference/diagnostics.md#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](/reference/manifest.md#tool-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](/reference/containment.md), `capabilities.network.allow`, the [untrusted
fence](#threat-model), 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`](/how-to/different-ways-to-run-murmur.md#step-5-inspect-the-capsules-reach-before-launching-it)
or in [`mur doctor`](/reference/cli.md#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`](/reference/observability-schemas.md#session-trace-tracejsonl).

## 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:

```yaml
# 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](/reference/manifest.md#hook-capabilities) for the full rules.

## Read-only paths

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

```yaml
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`](/reference/observability-schemas.md#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](/concepts/hooks.md#policy-hooks) 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](#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`](/reference/diagnostics.md#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](/reference/manifest.md#read-only-paths).

## Untrusted publisher text

Tool and skill descriptions rendered into [`MURMUR.md`](/concepts/session-loop.md#murmurmd) 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](#threat-model), 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](/concepts/session-loop.md#queue-lanes) 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`](/reference/observability-schemas.md#session-trace-tracejsonl)
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](#task-origin-and-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](/reference/untrusted-fence.md) 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](/reference/containment.md#subprocess-enforcement-tiers) for the
  breakdown and [`W-SEC-001`](/reference/diagnostics.md#w-sec-001) through
  [`W-SEC-003`](/reference/diagnostics.md#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.

### Recommended pattern: data/action phase separation

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.
