Skip to content

How to lock down a capsule's capabilities

An agent that runs with free rein over the host is one bug or one prompt injection away from unintended consequences — reading files it shouldn't, reaching hosts it shouldn't, or acting on attacker-influenced content. A production agent should instead run inside a capsule with precise scope grants, so it can carry out any use case within a boundary you control. Securing a capsule means granting the minimum each part needs — a capsule-wide ceiling of hosts, filesystem scope, and shell binaries. Optionally, you can narrow individual artifacts below that ceiling to further enforce access control.

This guide uses a coding capsule — an agent that clones a repository, edits files, and runs builds and tests through a shell — to explore those scope grants. That work needs real authority: shell access and a broad view of the filesystem. Granting it bluntly, with bash and an unrestricted filesystem and network, gets the job done, but it is exactly the free-rein posture above. The guide locks the capsule down step by step while keeping it able to do its job.

What is the capsule ceiling?

The ceiling is the capsule-wide top-level capabilities: block in murmur.yaml. It is the outer security boundary: the full set of hosts every artifact may reach, the filesystem scope every artifact may see, and the shell binaries the agent may invoke. A per-artifact grant can only ever narrow below it — subtract authority within the ceiling, never widen past it. The ceiling always wins.

The relevant manifest options are:

Option Controls
capabilities.network.allow The capsule-wide network ceiling every artifact clamps to
capabilities.filesystem.scope The capsule-wide filesystem scope every artifact clamps to
capabilities.shell.allow Which binaries the agent may invoke as shell tools
capabilities.shell.strip_env Glob patterns for host env vars to remove from the subprocess environment
capabilities.shell.baseline_env Additional host env vars to expose beyond the default baseline
artifacts[].capabilities Optional per-artifact grant that narrows one tool or driver below the ceiling
artifacts[].capabilities.network.allow Hosts one artifact may reach, intersected with the ceiling
artifacts[].capabilities.filesystem.scope Workdir subtree one artifact preopens instead of the whole workdir

The starting point: an unrestricted coding capsule

Create a murmur.yaml file. This version does the job — the agent has bash, so it can clone, edit, build, and test — but it grants that authority bluntly:

name: coding-agent
version: "0.1.0"

artifacts:
  - name: murmur-driver-anthropic
    version: "1.0.0"
    runtime: driver

capabilities:
  network:
    allow:
      - https://api.anthropic.com
  shell:
    allow:
      - bash

inference:
  transport: http
  endpoint: https://api.anthropic.com
  model: claude-sonnet-5
  api_key: ${ANTHROPIC_API_KEY}
  driver:
    artifact: murmur-driver-anthropic
name: coding-agent
version: "0.1.0"

artifacts:
  - name: murmur-driver-openai
    version: "1.0.0"
    runtime: driver

capabilities:
  network:
    allow:
      - https://api.openai.com
  shell:
    allow:
      - bash

inference:
  transport: http
  endpoint: https://api.openai.com
  model: o3-mini-high
  api_key: ${OPENAI_API_KEY}
  driver:
    artifact: murmur-driver-openai
name: coding-agent
version: "0.1.0"

artifacts:
  - name: murmur-driver-deepseek
    version: "1.0.0"
    runtime: driver

capabilities:
  network:
    allow:
      - https://api.deepseek.com
  shell:
    allow:
      - bash

inference:
  transport: http
  endpoint: https://api.deepseek.com
  model: deepseek-r1
  api_key: ${DEEPSEEK_API_KEY}
  driver:
    artifact: murmur-driver-deepseek

There is no filesystem.scope, so the capsule works out of the whole session workdir, and bash can run any command against the entire machine. The rest of this guide applies specific capabilities to constrain what the agent can reach — without taking away its ability to do the coding task.

What are binaries?

A binary is a standalone executable program on the filesystem that your shell can invoke by name when it appears on the system PATH. They differ from shell built-ins (like cd) that only exist inside the shell process itself and cannot be called as subprocesses.

Common examples by category:

  • Version Control & Repo Management: git, gh (GitHub CLI)
  • Environment & Scripting: python3, node
  • File & Directory Navigation: ls, pwd, find, grep
  • File Manipulation: cat, echo, rm, mv, cp, chmod, chown
  • Network Operations: curl, wget
  • System Information: whoami, uname, hostname

Each entry in shell.allow is a bare binary namebash, jq, python3. Paths are not accepted, and the binary must exist on the host's PATH.


Step 1 — set the capsule's capability ceiling

The top-level capabilities: block is the ceiling. Add a filesystem.scope so the capsule works out of the repo subtree of the session workdir rather than the whole workdir, and keep network.allow scoped to the single host the runtime needs for inference:

capabilities:
  network:
    allow:
      - https://api.anthropic.com   # your provider's inference endpoint
  filesystem:
    scope: repo
  shell:
    allow:
      - bash

filesystem.scope is a path relative to the session workdir — here <workdir>/repo, created on demand. This is the boundary every artifact in the capsule inherits and the subtree the coding agent checks the repository out into.

bash still reaches the whole machine on this platform

The ceiling's filesystem.scope and network.allow are only a real boundary for a bash subprocess when a kernel sandbox enforces them — Landlock plus seccomp on Linux with kernel ≥5.13. On macOS and Windows there is no kernel enforcement at all (W-SEC-001), and the Linux layer has not yet been verified on real hardware (W-SEC-005). Until then, treat these settings as advisory for bash: it can read and write files outside repo and open connections to hosts outside network.allow regardless of what the manifest declares (W-SEC-003). Pairing bash with any external-fetch capability is the maximum-risk combination the threat model describes — Step 4 shows how to move work into artifacts whose scope is enforced on every platform.

Prefer specific binaries over a full shell

bash is a full interpreter: it can run any command, chain and pipe them, and open its own connections. Granting it hands the model the widest possible shell surface. If the coding task can be expressed as calls to specific programs, list those instead — each listed binary becomes its own model-visible tool, and the arbitrary-command surface of a general shell is gone:

capabilities:
  shell:
    allow:
      - git
      - python3
      - jq

The agent can now run git, python3, and jq but has no general shell. Any bare binary name on the host PATH is accepted; there is no fixed list to choose from. This also removes the bash-specific network-bypass warning, which matches the literal string bash — declaring sh or zsh instead would silence W-SEC-003 without reducing the risk, so swapping one shell for another is not the point. Dropping the general shell is.

A narrower binary is still an arbitrary-execution vector

Listing specific binaries shrinks the tool surface the model sees, not what a running binary may do. A general-purpose program can still execute other programs — git -c core.sshCommand=..., python3 -c ..., and find -exec all run arbitrary commands. The kernel exec-allowlist that would confine a granted binary to executing only other allowlisted binaries is Linux-only and unverified (W-SEC-005), so on a typical host this narrowing is least-privilege intent, not a containment boundary. Declare the narrowest set the task genuinely needs, and push anything you can into the scoped tool artifacts of Step 4, whose filesystem and network scope is enforced on every platform.


Step 2 — manage the subprocess environment

The subprocess does not inherit your full host environment. It starts with a minimal baseline: PATH, HOME, USER, LANG, LC_ALL, TMPDIR, TEMP, TMP, CARGO_HOME, RUSTUP_HOME, and TERM. Known credential-shaped variables are always stripped before the subprocess spawns, regardless of any other configuration — this includes exact names (ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_API_KEY, HUGGING_FACE_HUB_TOKEN, NEXUS_API_KEY, GITHUB_TOKEN, GH_TOKEN, KUBECONFIG, NPM_TOKEN, PYPI_TOKEN, CARGO_REGISTRY_TOKEN) and glob patterns (AWS_*, DOCKER_*, *_API_KEY).

HOME is always synthetic

HOME (and, on Windows, USERPROFILE) is never the real host home directory. The runtime always replaces it with a session-scoped directory (<workdir>/.capsule-home, created on demand) before the subprocess spawns. Neither baseline_env nor any environment override the agent supplies can restore the real host value — the synthetic path always wins.

Native tool artifacts get the same treatment

Everything in this step — the synthetic HOME, the credential-pattern strip list, strip_env, and baseline_env — also applies to runtime: tool artifacts declared with implementation: native (see Python Tool Quickstart (Native)), even if the capsule declares no capabilities.shell.allow entries at all. Both subprocess spawn paths build their environment through the same internal function, so there's no separate configuration surface for native tools.

Use strip_env to remove baseline variables the agent doesn't need:

capabilities:
  shell:
    allow:
      - bash
    strip_env:
      - CARGO_HOME
      - RUSTUP_HOME

Each entry is an exact variable name or a glob with the * at the start, the end, or both:

strip_env:
  - AWS_*        # matches AWS_DEFAULT_REGION, AWS_PROFILE, and any other AWS_-prefixed var
  - MY_CORP_*    # matches any var starting with MY_CORP_
  - "*_TOKEN"    # matches any var ending in _TOKEN
  - "*SECRET*"   # matches any var with SECRET anywhere in the name

Step 3 — add host variables beyond the default baseline

The default baseline only includes variables the runtime considers universally safe. Any host variable outside that fixed set — a database URL, an API endpoint, an application config path — is excluded unless you explicitly add it.

Default baseline variables
Variable Purpose
PATH Locates executables on the host
HOME Session-scoped synthetic directory, not the real host home (see above)
USER Current username
LANG System locale
LC_ALL Locale override for all categories
TMPDIR Preferred temporary directory (macOS/BSD)
TEMP Preferred temporary directory (Windows)
TMP Fallback temporary directory
CARGO_HOME Rust package cache directory
RUSTUP_HOME Rust toolchain installation directory
TERM Terminal type identifier

Use baseline_env to bring additional host variables into the subprocess environment:

capabilities:
  shell:
    allow:
      - bash
    baseline_env:
      - DATABASE_URL
      - APP_CONFIG_PATH

The runtime builds the final subprocess environment in order: it starts from the default baseline, appends any variables listed in baseline_env, then applies strip_env removals (chained with the built-in credential patterns), and finally sets HOME/USERPROFILE to the synthetic session directory. This means strip_env always wins over baseline_env for everything except HOME/USERPROFILE, which are fixed last and can't be reintroduced by either setting — a variable that appears in both lists is not passed to the subprocess:

capabilities:
  shell:
    allow:
      - bash
    baseline_env:
      - DATABASE_URL
    strip_env:
      - CARGO_HOME
      - RUSTUP_HOME

This example adds DATABASE_URL from the host and removes CARGO_HOME and RUSTUP_HOME from the default baseline.


Step 4 — narrow individual tools and drivers below the ceiling

The strongest way to constrain a coding agent is to do less through bash and more through scoped tool artifacts. A per-artifact filesystem.scope on a WASM tool is a real WASI preopen — the wasm runtime enforces it on every platform, macOS included, where bash's own confinement is only advisory. Moving a task out of bash and into a scoped tool turns an advisory boundary into an enforced one.

Add the WASM tools this agent uses to the artifacts: list, add the one extra host they need to the ceiling, and scope each artifact to only its slice:

artifacts:
  - name: murmur-driver-anthropic
    version: "1.0.0"
    runtime: driver
    capabilities:
      network:
        allow:
          - https://api.anthropic.com
  - name: murmur-tool-git
    version: "1.0.0"
    runtime: tool
    capabilities:
      network:
        allow:
          - https://github.com
  - name: murmur-tool-editor
    version: "1.0.0"
    runtime: tool
    capabilities:
      network:
        allow: []
      filesystem:
        scope: repo

capabilities:
  network:
    allow:
      - https://api.anthropic.com
      - https://github.com
  filesystem:
    scope: repo
  shell:
    allow:
      - bash

The effective grant is the intersection of what an artifact declares and the ceiling — it can only ever subtract. Each block does one job:

  • The driver reaches only api.anthropic.com, dropping github.com from the ceiling. A driver narrows through the same path as any WASM tool, so this also applies to a driver call a hook's run-inference makes.
  • The git tool reaches only github.com, dropping the inference endpoint it never calls.
  • The editor tool gets network.allow: [] — a real narrowing to zero outbound HTTP, distinct from omitting the key — and filesystem.scope: repo, which preopens only <workdir>/repo as its current directory. An absolute path, or one that escapes via .., fails at staging (E-CAP-002) before the tool runs.

An artifact with no capabilities: block inherits the full ceiling. Grants are read only from your capsule manifest's artifact entry, never from the artifact's own bundled murmur.yaml, so an untrusted artifact cannot scope itself up.

Write the narrowing at least as specific as the ceiling entry

A bare host like github.com spans both schemes and every port, so it is broader than a https://github.com ceiling entry and does not fit under it. The runtime drops the uncovered entry and prints a W-SEC-007 warning naming the artifact and the dropped entry — the artifact ends up with less access than asked for, never more. Match the ceiling entry exactly. This is the most common way to trip the warning by accident.


Step 5 — install and run

mur install
Different ways to install artifacts

mur install needs to know where to fetch artifacts from. You have two options:

Option A — configure a registry source in ~/.murmur/config.yaml:

registry:
  default: official
  sources:
    - name: official
      type: github
      repo: <owner>/<repo>
      token: "${GITHUB_TOKEN}"

Then install by artifact name and version:

mur install <artifact-name@version>

Option B — pass a full GitHub reference and skip configuration entirely:

mur install github:<username>/<repo>@<tag>

See Installing artifacts to learn more.

Create a task.md file describing the coding task:

Clone the repository, run the test suite, and report any failures.

Run the capsule:

mur run --task task.md
murmur: url localhost:52222
session: ses_019ed2af53da75c2aefee84ee10c34af
status:  ok

Check workdir/<session_id>/MURMUR.md. The Running Shell Commands section lists the binaries the agent has access to:

## Running Shell Commands

Declared shell binaries: bash
Call them via the shell-execution interface or directly from bash if `bash` is in the allowlist.

Each shell invocation the agent makes appears in workdir/<session_id>/trace.jsonl as a shell event:

{"event_type":"shell","turn":0,"command":"git status","exit_code":0,"stdout_bytes":124,"stderr_bytes":0,"duration_ms":12}

Step 6 — watch for warnings and keep secrets out of the manifest

Per-artifact grants resolve at staging, before the session workdir exists, so any capability warning goes to stderr as the run starts. Two are worth knowing:

  • W-SEC-007 — a per-artifact network.allow entry the ceiling does not itself cover was dropped, not granted. Nothing is ever widened, but a tool that silently cannot reach a host it looks "granted" is hard to trace back to the manifest. See W-SEC-007.
  • W-SEC-008 — a per-artifact block declared shell, spawn, env, or limits, or sat on a tool with a native (non-WASM) implementation. Only network and filesystem narrow; the rest parse but are inert. Scope a native tool through the capsule-wide capabilities.shell.* block instead, or ship it as WASM if you need per-artifact narrowing. See W-SEC-008.

Keep credentials out of the manifest itself. Reference them by environment variable — api_key: ${ANTHROPIC_API_KEY}, as every example above does — never as a literal string. A literal secret prints a W-SEC-004 warning and risks leaking into version control.

To confirm what each artifact actually did during a run, inspect the trace:

mur trace show
Different ways to identify a session

mur trace show locates a session three ways:

No argument — most recent session:

mur trace show

Finds the lexicographically largest ses_* entry in workdir/ — always the most recently created session, because session IDs are UUID v7 (time-ordered).

Short suffix — type the last few characters:

mur trace show 3e4b

Matches any session whose ID ends with the given string. Use 4 or more characters to avoid ambiguity. Matching is case-insensitive. If the suffix matches more than one session, mur lists the candidates and asks you to be more specific.

Full session ID:

mur trace show ses_6801f81dd28b4a9daf434e8324c4793e

Resolves the session directly without scanning workdir/.

Legacy file path:

mur trace show path/to/trace.jsonl

Any argument containing / or ending in .jsonl is treated as a literal file path. Kept for backward compatibility.

Use --workdir <path> if your session directory is not ./workdir.

Other trace exploration commands

mur trace has four subcommands for exploring session output:

mur trace show — print the full trace for a session to the terminal. Accepts a session ID, suffix, or no argument (most recent session).

mur trace steps — show a turn-by-turn summary of what the agent did in a session. Pass --verbose to include a truncated summary of each tool's input.

mur trace diff — compare the traces of two sessions side by side. Useful for spotting behavioural regressions between runs.

mur trace report — generate a structured summary report from a session's trace. Covers token usage, tool calls, latency, and other session-level metrics.


Summary

Manifest setting Effect
capabilities.network.allow (top-level) The ceiling; every artifact clamps to it and none can widen past it
capabilities.filesystem.scope (top-level) The workdir subtree the capsule works out of; enforced for bash only under a verified kernel sandbox
shell.allow: [bash] Exposes bash as a model-visible tool; unconfined on hosts without kernel enforcement
shell.allow: [git, python3, jq] Grants specific binaries instead of a full shell; shrinks the model's tool surface, though each remains an arbitrary-execution vector
shell.strip_env: [CARGO_HOME] Removes a specific variable from the default baseline
shell.strip_env: [AWS_*] Removes all vars whose name starts with AWS_
shell.baseline_env: [DATABASE_URL] Exposes an additional host variable not in the default baseline
HOME / USERPROFILE Always the synthetic session directory; cannot be overridden by baseline_env or a tool-supplied env value
artifacts[].capabilities present Narrows that one tool or driver; effective grant = declaration ∩ ceiling
artifacts[].capabilities absent Artifact inherits the full ceiling
network.allow: [] on an artifact Denies that one artifact all outbound HTTP; siblings keep theirs
filesystem.scope on a WASM artifact A real WASI preopen, enforced on every platform including macOS
Bare host under a scheme-bound ceiling Dropped with W-SEC-007; write the entry as specifically as the ceiling
shell/spawn/env/limits in a per-artifact block Parsed but inert; prints W-SEC-008
api_key: ${ENV_VAR} Keeps secrets out of the manifest; a literal triggers W-SEC-004
Non-zero exit code Passed back to the model as data; the session continues