Resource Plane
A capsule serves two read-only file surfaces over its HTTP listener, under the /resources/ path
prefix. Each is declared separately, addresses a separate subtree, and answers a different
audience.
| Plane | Declared by | Audience | Addressed by | Verbs |
|---|---|---|---|---|
| Operator plane | exports.files |
An external process the operator runs | Path | list, read |
| Peer plane | exports.peer_files |
One named peer capsule | An opaque handle | read |
Declaring one grants nothing about the other. A capsule may declare either, both or neither, and both default to deny.
Operator plane
A capsule that declares exports.files opens a read-only view onto
part of its accessible workdir — the directory the agent's own tools see as .. An
external process lists and reads the files under that subtree.
The runtime serves these reads itself, off the host path it already holds for the workdir. The agent is not involved, so a read costs no inference turn and is answered whether the capsule is idle or in the middle of a task.
A manifest with no exports.files has no resource plane: every request to it is refused with
no_resource_plane.
Endpoints
| Method | Path | Answers |
|---|---|---|
GET |
/resources/files |
The listing |
GET |
/resources/files/<relpath> |
One file's bytes |
Every other method under /resources/ returns 405 with an allow: GET header. There is no write
path: no PUT, POST, PATCH, DELETE, mkdir or delete verb exists.
GET /resources/files
Recursive over the whole subtree, regular files only, sorted lexicographically by path. path is
relative to the export root, /-separated. size_bytes, mtime_ms and sha256 all come from a
single open of that file.
{"root":"out/","mode":"read-only","max_bytes":10485760,"generation":0,"containment_achieved":"sealed","entries":[{"path":"report.md","size_bytes":32,"mtime_ms":1787577327015,"sha256":"868db1c52a946cae0278871d41b60fcdef206b933946bf8668a42ac1ea443412"}]}
A file larger than max_bytes is listed with its real size and refused on read. A root that does
not exist returns 200 with entries: [] — exports.files.root need not exist when the capsule
launches, and only an undeclared plane refuses a listing.
GET /resources/files/<relpath>
200, content-type: application/octet-stream, body is the raw bytes:
HTTP/1.1 200 OK
content-type: application/octet-stream
etag: "sha256:868db1c52a946cae0278871d41b60fcdef206b933946bf8668a42ac1ea443412"
x-murmur-mtime-ms: 1787577327015
x-murmur-generation: 0
x-murmur-containment: sealed
x-murmur-export-root: out/
connection: close
| Header | Value |
|---|---|
etag |
"sha256:<64 hex>" of the bytes in this response |
content-length |
Bytes in this response |
x-murmur-mtime-ms |
The file's modification time, Unix milliseconds |
x-murmur-generation |
Completed tasks in this runtime process — see Generation |
x-murmur-containment |
advisory, scoped or sealed — the class this session achieved |
x-murmur-export-root |
exports.files.root verbatim |
A read always serves the file's current bytes. No request parameter selects an earlier version, and no earlier version is retained.
Errors
Every refusal returns a JSON body {"error": "<code>", "message": "<text>"}.
| Status | error |
When |
|---|---|---|
| 404 | no_resource_plane |
The manifest declares no exports.files |
| 404 | not_found |
The path names nothing under the root, or the root does not exist |
| 403 | outside_root |
The requested path, or a symlink's target, resolves outside the export root |
| 403 | symlink_refused |
A symlink was encountered and the achieved containment class is scoped |
| 403 | not_a_regular_file |
A directory, fifo, socket or device node |
| 413 | too_large |
The file exceeds exports.files.max_bytes |
| 405 | method_not_allowed |
Any method other than GET under /resources/. The response carries allow: GET |
| 500 | io_error |
The open or read failed for any other reason |
Every request lands in trace.jsonl as a
resource_list or resource_read event carrying the same code as its outcome — refusals as well
as successes.
Path resolution
Shared by both planes: a redeem on the peer plane resolves its handle's path by exactly these
rules, against exports.peer_files.root.
Path resolution belongs to the runtime. A caller never sanitises a path: a request that would leave the export root is refused, never normalised into something servable.
For every read, in this order and before any byte is served:
- Percent-decode the path.
- Refuse, without touching the filesystem: an empty path, an absolute path, a NUL byte, and any
path component equal to
"","."or"..". - Canonicalise the export root and the target, and require the target to be the root or beneath it.
- Open the final component with
O_NOFOLLOWand confirm from that descriptor that it is a regular file.
Symlinks are decided by the achieved containment class — see Symlinks under an export root.
An exports.files.root that already resolves outside the accessible workdir, because it exists as a
symlink pointing out of it, refuses the launch with
E-CAP-007 before any session runs.
Generation
Shared by both planes: a peer redeem reports the same counter.
x-murmur-generation on a read, and generation in a listing, count the tasks that have reached a
terminal state in this runtime process. It starts at 0 and increments by one per completed task.
It is provenance: it answers these bytes are as of turn N. It is not a selector. No request
parameter chooses a generation, no response is refused because the generation moved on, and no
superseded bytes are kept. A capsule that is finished but still alive and rewrites a file two turns
later serves the newer file; etag is how a caller notices the content changed.
The generation and the validator move independently: two reads of an unchanged file across a
completed task return the same etag and different generations.
A runtime process that has completed no task reports 0, which is the correct answer whether the
session has just started or the process was launched fresh over an existing workdir.
Writing files an external reader will read
Shared by both planes: a peer redeem calls the same reader.
A read is atomic against a concurrent rewrite in the way that matters for the response itself: the
file is opened once and its bytes, size and mtime all come from that one descriptor, and the
etag is computed over the buffer actually served. The etag therefore always describes the body
it was sent with, whatever the agent is doing to the file at the time.
Whether the body is one whole version of the file is up to how the capsule writes it:
| The agent writes by | A concurrent read sees |
|---|---|
Writing a temp file and renameing it over the target |
One whole version. The reader's open descriptor stays on the old inode for the whole read; the next request opens the new one |
| Truncating and rewriting the target in place | Possibly a mix of the old and new bytes, or a short body |
Write a temp file and rename it into position. An agent that rewrites in place can be read
mid-write, and the reader has no way to tell a torn body from a whole one — the etag matches
either way, because it describes exactly what was served.
Reading after the runtime exits
Read these files through the resource plane rather than off disk. A gateway that opens the workdir directly gets no export check, no path checks and no trace record.
Workdirs persist past teardown, so a later read means relaunching the runtime against the
same workdir and re-requesting. A relaunched process reports generation: 0, because no task has
completed in it; etag remains the validator that tracks content.
Peer plane
A capsule that declares exports.peer_files can hand one
named file to one named peer without a filesystem path crossing the wire. The runtime mints an
opaque handle; the agent puts that handle in an ordinary A2A message; the peer redeems it.
A capsule that declares capabilities.peer_fetch can redeem such a
handle and lands the bytes as a file in its own workdir.
Both halves are separate operator decisions, and neither implies the other:
| Declaration | What the capsule gains |
|---|---|
exports.peer_files |
The share-file tool, and a peer plane that answers redeems |
capabilities.peer_fetch |
The fetch-peer-file tool |
| Neither | No tool exists, and /resources/peer/ answers no_peer_plane |
Declaring either leaves the achieved containment class unchanged.
The agent's two tools
Each tool appears in the model's inventory only when its own grant is declared. An undeclared capsule's model never sees that the tool exists.
share-file
| Field | Required | Meaning |
|---|---|---|
path |
yes | Relative to exports.peer_files.root |
peer |
yes | The peer's address, as host:port or an http(s) URL |
ttl |
no | A duration. May only narrow: a value above max_ttl is clamped down to it. Absent means max_ttl |
{"handle":"mh1.eyJ2IjoxLCJpc3M...","handle_id":"3f2a91c40b7e5d68","expires_at_ms":1755953600123,"audience":"reporter@localhost:41234"}
No field of the result is a filesystem path. A path that escapes the root — .., absolute,
percent-encoded, or a symlink leaving the root — fails the mint and is refused, never normalised
into something mintable.
fetch-peer-file
| Field | Required | Meaning |
|---|---|---|
peer |
yes | The peer's address. Checked against capabilities.peer_fetch.allow before any connection opens |
handle |
yes | The handle the peer sent |
{"path":"peer-in/3f2a91c40b7e5d68-report.md","bytes":812,"sha256":"6b86b273ff34fce1...","generation":3,"peer":"localhost:41234"}
The bytes arrive as a file and never as text. No field of the result holds the file's contents,
so ingesting a peer's bytes is a file the agent must decide to read rather than context it is
handed. The stored path is chosen by the runtime: peer-in/<handle_id>-<sanitised basename> under
the accessible workdir.
The handle
mh1.<base64url-nopad(payload JSON)>.<base64url-nopad(HMAC-SHA256)>
The payload is in the clear and is not the secret:
{"v":1,"iss":"ses_01a033ec2c1f7eb2a5922f84e012e89d","p":"report.md","exp":1755950300000,"n":"7f3c…"}
| Field | Meaning |
|---|---|
v |
Payload version. Only 1 is minted or accepted |
iss |
The minting session's id — the capsule instance, not the capsule |
p |
Path relative to exports.peer_files.root, canonicalised at mint time |
exp |
Absolute expiry, Unix milliseconds |
n |
16 random bytes, lowercase hex, so two handles for the same file and audience are distinct |
There is no generation field. A handle authorises a file, not a version of one: a redeem always
serves the file's current bytes, so a file rewritten two turns later serves the newer content and
the etag is how the holder notices. No 409 is reachable on this plane.
Redeem is idempotent, not single-use. The same handle redeems as many times as the holder likes until it expires or the minting session ends. There is no used-set and no per-handle server state.
handle_id is the first 16 lowercase hex characters of sha256(<token>). It is what appears in
traces and error messages; the token itself never does.
The audience
The MAC covers the audience, but the token does not carry it:
HMAC-SHA256(instance key, "murmur-peer-handle-v1" ‖ 0x1f ‖ <payload base64url> ‖ 0x1f ‖ <audience>)
Both sides compute the audience without exchanging it, because both compute it from the fetching
capsule's own advertised identity. At mint, the minter fetches the peer's agent card from
GET /.well-known/agent-card.json and reads its name and url; at redeem, the fetcher asserts
the same two fields of its own identity in an x-murmur-audience header. In both cases the string
is <name>@<host:port>, lowercased.
That card fetch is an ordinary outbound request and is enforced against
capabilities.network.allow. Minting grants no new outbound authority. A peer that cannot be
reached, or whose card carries no name and url, fails the mint with peer_unreachable.
Audience binding is not peer authentication
Nothing proves that the process asserting x-murmur-audience: reporter@localhost:41234 is
that capsule. An attacker who both intercepts a handle and knows which peer it was minted for
can assert that identity and redeem.
What the binding buys is that a handle is not a credential for whoever finds it: a third capsule with its own identity is refused, and a token alone is insufficient.
The minting key
32 random bytes, generated at launch and only when exports.peer_files is declared. Held in
memory, never written to disk, never placed in an environment variable, and overwritten when the
session ends.
When the session ends every outstanding handle becomes unverifiable at once — revoke-all with no revocation list. A handle minted by one session of a capsule does not redeem against a second session of the same capsule over the same workdir.
That is also why a persistent capsule must declare a short handle lifetime:
lifecycle.after_task |
exports.peer_files.max_ttl |
|---|---|
exit (the default) |
Optional. Defaults to 1h. No ceiling — teardown is the real bound |
sleep |
Required, and at most 15m. Absent or longer refuses the launch with E-CAP-008 |
A handle's lifetime is not a durability mechanism. A consumer that needs these bytes after the capsule is gone should have the operator relaunch the runtime against the still-present workdir and request again.
GET /resources/peer/<handle>
One verb, and no way to enumerate. GET /resources/peer and GET /resources/peer/ return 404
not_found; there is no list, no path addressing and no write path.
The request requires an x-murmur-audience header. A 200 carries the raw bytes and the same
validator headers a read on the operator plane carries, plus x-murmur-handle-id:
HTTP/1.1 200 OK
content-type: application/octet-stream
etag: "sha256:868db1c52a946cae0278871d41b60fcdef206b933946bf8668a42ac1ea443412"
x-murmur-mtime-ms: 1787577327015
x-murmur-generation: 3
x-murmur-containment: sealed
x-murmur-handle-id: 3f2a91c40b7e5d68
content-length: 32
connection: close
It never carries x-murmur-export-root: the peer plane discloses no path structure.
A redeem calls the operator plane's reader, so it inherits that reader's guarantees unchanged — see Path resolution and Writing files an external reader will read.
Checks run in a fixed order, and nothing downstream of the MAC is evaluated before it:
- Parse the token's shape.
- Require the
x-murmur-audienceheader. - Verify the MAC, in constant time.
- Check the expiry.
- Resolve the path and serve.
| Status | error |
When |
|---|---|---|
| 404 | no_peer_plane |
The manifest declares no exports.peer_files |
| 400 | malformed_handle |
Not mh1.<b64>.<b64>, or the payload is not JSON, or v != 1 |
| 400 | missing_audience |
No x-murmur-audience header, or it is empty |
| 403 | handle_not_valid |
MAC verification failed — for any reason |
| 410 | handle_expired |
exp is in the past |
| 404 | not_found |
The named path no longer exists under the root |
| 403 | outside_root |
The named path resolves outside exports.peer_files.root |
| 403 | symlink_refused |
A symlink was refused under the class-keyed rule |
| 403 | not_a_regular_file |
A directory, fifo, socket or device node |
| 413 | too_large |
The file exceeds exports.peer_files.max_bytes |
| 405 | method_not_allowed |
Any method other than GET. The response carries allow: GET |
| 500 | io_error |
The open or read failed for any other reason |
handle_not_valid is one code on purpose. A tampered payload, a handle minted by a different
capsule instance, and a correct handle presented with the wrong audience produce the same status,
the same code and the same message text. Splitting them would build an oracle that tells a prober
which field to change next.
What lands in the trace
Three event types, written at the moment of the event and described in full under Observability Schemas:
| Event | Written by | Side |
|---|---|---|
peer_handle_mint |
The share-file tool |
The minter |
peer_handle_redeem |
The listener, concurrently with any running task | The minter |
peer_file_fetch |
The fetch-peer-file tool |
The fetcher |
All three carry a handle_id, and a mint's and its redeems' are equal. Refusals are recorded as
well as successes. The token itself never appears in a trace on either side — anywhere it would
otherwise reach one, including as a recorded fetch-peer-file argument, it is replaced with
<handle:<handle_id>>.
Limits of a handle
| Limit | What it means |
|---|---|
| A handle is terminal | It is redeemable only at the audience it was minted for. A recipient cannot mint a derived handle from one it received, and the redeem path has no notion of a delegation chain |
| The audience is not authenticated | See The audience |
| Only the agent loop can mint or redeem | share-file and fetch-peer-file are agent-loop tools. No WIT import exposes either to a wasm component |