Filesystem grants
What an agent may read and write is declared in its profile and installed into a kernel sandbox at startup. Those are not the same thing, and the gap between them is where the confusing failures live โ a grant can be written down, dropped by policy, and never reach the kernel at all.
mur agent perm list-paths mur
agent 'mur' โ sandbox enforcing (macos-sbpl)
READ
โ /Volumes/Firecuda4tb/Projects/mur
โ /Volumes/Firecuda4tb/Projects/cc-proxy
dropped โ path does not exist on disk
โ /Users/david/.mur/skills
Three states, not two
| State | Where it lives | What it means |
|---|---|---|
| granted | profile.yaml | You asked for it |
| admitted | after policy | It survived the checks โ a path that does not exist on disk is dropped |
| enforced | the running sandbox | The kernel actually received it |
list-paths shows all three at once: โ for a grant that reached the kernel, โ dropped with the reason, and a plain ยท for a grant recorded but not yet enforced because the agent is not running.
The header is the fourth fact โ whether the sandbox is enforcing at all, and which backend installed it. An agent whose seal failed is not an agent with no grants; it is a different situation entirely, and the two used to look identical.
No approvals without a sealed sandbox. An agent running advisory-only (fail_closed_on_sandbox_error: false and the kernel seal failed) can read its own signing key, so any approval it holds could be forged. On that boot, tools that need approval are refused outright instead of asked โ including ones you approved earlier. The refusal points back at mur agent perm list-paths <agent>, whose header shows the seal state. Tools set to allow still run. Restarting does not help when the seal fails for a platform reason (no Landlock, sandbox_init refused); it fails the same way every time.
Granting
mur agent perm allow-read mur /path/to/tree
mur agent perm allow-write mur /path/to/tree
mur agent perm deny-path mur /path/to/secret
mur agent perm list-hosts mur
Grants are recursive: allowing a directory allows everything under it. Changes take effect on the agent's next start โ the sandbox is installed once, at startup, and cannot be widened afterwards.
allow-write refuses a path that does not exist, so a write grant is always for something real. When a write is denied at run time, the agent's own error names the nearest existing ancestor you could grant instead of the path it tried.
Why a grant gets dropped
The sandbox backends differ in what they can express. Linux Landlock has no deny rule, so an allow that overlaps a protected path has to be dropped whole; macOS SBPL can install the allow and re-close the protected paths after it. A grant that works on one host can therefore be dropped on the other โ which is why the drop is reported per path, with its reason, rather than summarised.
The kernel is not the only gate. MUR's tools check the same grants in-process before acting, and on Linux that check is the only place a deny-within-allow is enforced, precisely because Landlock cannot express one. A path can therefore be refused by the tool while the kernel would have allowed it โ see Troubleshooting for what that looks like.
Two files are always denied to the agent itself, on every backend: its own profile.yaml and its identity.key. An agent cannot read its own configuration or its private key, so anything the agent needs to know about its own grants has to be handed to it, not read back.
That is self-protection, and it is the narrow case. A larger set of paths is refused to every agent โ other agents' directories and signing keys, ~/.mur/secrets, ~/.mur/auth.json, the host identity.key, the runtime binary and the autostart directories. Those are listed with their reasons under Troubleshooting.
Delegation checks the grant first
When work is handed to other agents, each member is told which directory to work in: the git repo root of the target, or the directory itself outside a checkout. Before anything is dispatched, MUR checks that every member may write there. A member never discovers a missing grant by building in the wrong tree.
mur fleet run builder --cwd /path/to/repo # members routed to the repo root
mur workflow run release-notes --channel-new # delegate_to steps are gated too
Three places dispatch to members, and all three go through the same check:
| Where | Target directory |
|---|---|
mur fleet run | --cwd, or the shell's directory when run by hand |
parallel_jobs (MCP tool) | its cwd argument, or the calling agent's session directory |
a workflow's delegate_to steps | the current directory's repo root |
For each member and directory, in order:
- The directory must exist โ a grant for a missing path would be dropped when the sandbox seals, so it is refused here instead. A target that covers the whole machine, the whole home directory, or a volume root is refused as too broad.
- A
denyentry is final โ the dispatch is blocked with no offer to grant. - Granted and not guessed โ it goes through silently.
- Anything else asks once โ as a
write-tier approval. If you approve, the directory is added to the member'sfilesystem.write, the sandbox is resealed, and the member is restarted if it runs as a service. A member you started by hand is never respawned by MUR. The dispatch stops and tells you to runmur agent restart <member>.
A target that was guessed is always confirmed: an agent's session directory filled in because the call named none, or a workflow's current directory. That holds even when the grant already exists. The member's instructions also say the directory was assumed, so a wrong guess is visible rather than silent.
The check runs per member, not per job: ten jobs to one member raise one approval. Nothing is dispatched until every member passes. Unattended, the approval parks instead of timing out, and the message names the mur channel approve <channel> <hitl_id> that releases it.
Two things worth knowing:
- A workflow only delegates on a channel run. Without
--channel-newor--channel <id>,delegate_tosteps print their description and call no one, so there is nothing to gate.mur workflow runwarns when that happens. - A loop is checked once, and only when you start it.
mur fleet run --looproutes every iteration to--cwd(or your shell's directory) and runs the check once, before the first iteration and before the deadline clock starts, so time spent approving is not taken from the run. If the check stops it, no job is claimed: queued work stays queued for the next run. A loop the daemon starts on a schedule, a loop started from the MUR Hub, andmur deep-researchhave no target directory, so they are neither routed nor checked.
Related
- Build Lane โ
allow-spawn-dir, for toolchains that compile and then run their own output. - Unattended Approvals โ what happens to a grant approval when nobody is there to answer.