Status: design, agreed (2026-09-27; decisions at the end). Built in steps, each noted where it lands.
Code in a room's sandbox (an agent's, or an MCP client's clojure_eval) reaches the world
through the capabilities dvergr injects: files, HTTP, databases, rooms, models, mail,
schedules, processes. Today each capability guards and records itself, or does not. One
boundary through which every effect passes gives, as ordinary handlers instead of per-feature
code:
is.simm.model.access/can?, eacl behind it later);Guards: HTTP domain policy (dvergr.sandbox.ns.io/make-domain-policy), SSRF guard, secret
substitution and scrubbing, the sensitive-path policy and path clamps, muschel shell permits,
the dependency approval gate (dvergr.sandbox.deps), the host-namespace mirror allowlist, the
JVM class allowlist (lock-interop!), the delegation ceiling for hire!/run-experiment!,
the certified-evaluation write guard, self-delete/self-close guards, memory/timeout limits.
Records: acquisition receipts (HTTP only, only with a control-room store and a Run); tool-call
records (one per tool call: a clojure_eval is one record, the effects inside it are not);
an IO audit log that is built and then discarded (rebind-working-ctx!).
Where a boundary goes: nearly every capability is injected by
dvergr.sandbox/setup-agent-namespaces! (plus add-bash-ns!, add-media-ns!,
add-process-ns! from rebind-working-ctx!, consumer injectors, and dvergr.mcp.repl);
registry tools pass dvergr.tools/execute, which already has an allowlist and a per-tool
:authorize hook.
The inventory found holes that are bugs today. They are fixed before the boundary, in their
own PR, each with a test that fails first. Fixed (test/dvergr/sandbox/hardening_test.clj):
1, 2, 3, 4, 6, 7, 8; 5 is the boundary's receipt stream (below).
For 7 and 8 the working context carries the acting identity (:agent-id, from the agent the
context was built for; MCP connections act as :mcp/<profile>) into the sandbox's world
binding, where code cannot change it. post! authors as that identity (:sandbox when there
is none) and refuses :from/:source-user…. An agent changes its own actor row and those of
agents it spawned (:spawned-by in their config), cannot re-spawn an existing id or register
a human, settles tasks assigned to or dispatched by it or posted in its room, and rewrites
only its own prompt. The host and MCP connections keep their reach.
clojure.data.xml/parse of a non-string (a java.net.URI is an allowed class) runs the
host's slurp: a network and file read past every guard. Accept strings and readers the
sandbox produced only.tools/tool-path); clj_kondo reads host paths. Clamp to the
workspace root like the sandbox's own fs-safe-resolve.add-tap/remove-tap register sandbox fns in the JVM-global tap set: a side channel
across sandboxes. Withhold them (keep tap>).llm/*, vision/*, doc/* call providers without the budget check and token accounting
the llm_call tool has. Route them through the same accounting.processes/directive! :extend-budget lets an agent extend its own budget; only a
supervisor or the room's owner may.post! accepts :from, :source-user… from the caller: authorship is spoofable. The
runtime sets them from the acting identity.update_agent_profile (any agent's prompt), dvergr.actors/*
and dvergr.tasks/* (system DB). Scope them to the acting agent and its rooms until the
authority handler decides them.An effect is a map describing what code asks the world for:
{:effect :http/request ; namespaced kind, from a closed vocabulary
:class #{:network :egress} ; what it touches: :read :write :network :egress :spend
; :process :lifecycle :schedule :global
:resource {:url "https://…"} ; the object acted on (a room, a path, a URL, a KB…)
:subject {:party … :run … :agent …} ; who, from the runtime, never from the caller
:room :r :world "…" ; where the code runs
:request {…}} ; the arguments, canonical (hashable for replay)
Each injected capability becomes a thin perform of its effect; the implementation that does
the work is the executor at the end of a handler chain. The chain is configured per
evaluation (per Run, per MCP connection), not per capability:
can? subject action resource, the one predicate shared with simmis
(is.simm.model.access). First over the relations that exist (party → room → grant →
system); eacl on Datahike behind the same seam later. Cross-room effects (fork!,
merge!, post!, messages on another room) are decided here instead of being open.:spend reserve and debit (Kontor): model calls, and
eval CPU/wall time as a recorded resource (not charged; see metering).:live executes;:read-only denies every effect whose class is not #{:read};:replay answers from the effect log by (effect, canonical request), and fails a
request it has no answer for (a replay that diverges says so);:faults executes, then (or instead) returns a failure drawn from a per-effect
distribution: error, timeout, rate limit, truncated or corrupted result; seeded, so a
faulty run is reproducible;:preflight executes nothing: reads may be served from a snapshot or a stand-in,
writes and egress return a stand-in result, and the effect is recorded as planned.The receipt belongs to the runtime, not the capability: an SCI function cannot fabricate the
authority it ran under (as simmis's doc/tool-authorization.md requires).
dvergr.effects)The chain above is a stack of handlers in the algebraic-effects sense. An effect is data
(an operation from the closed vocabulary, with a malli signature for its resource and result);
a capability performs it with perform! and the function that does the real work, which is
the innermost handler (the world). A handler is (fn [effect next] value) and does one of three
things: answers with a value (replay, a fault, a preflight stand-in), refuses by
throwing, or forwards with next, doing something around it (receipts, metering,
containment). The receipts handler is outermost and records every effect with who decided or
answered it.
Handlers resume once and at once (tail resumption), so no continuation is captured; SCI code is not CPS-transformed. Resuming later (an approval) parks the calling thread on a deferred. Resuming several ways (a counterfactual: "what if this search had returned that") re-executes against the receipt log: the log is the trace, replay reproduces it, a fault is an intervention on it. Code running inside spindel spins could later get the same handlers with real continuations, without changing capabilities.
Configuration is data and composes algebraically. A world's handlers live in its binding
(:effects {:handlers [[:admit #{:read :network}] [:read-only]]}), set by the runtime and
unreachable from code; they fork with the world. A refusing handler is a filter admit S
(read-only is admit #{:read}); filters compose by intersection, so they commute, are
idempotent, and admitting every class is the identity. normalize gives the canonical stack:
receipts, one filter, then answering handlers in the order given, then the world. compose
concatenates and normalizes; it is associative with [] as identity. A rebind or fork
composes its handlers onto the world's, so authority narrows and never widens (attenuation,
as in simmis's delegation). These laws are test.check properties
(test/dvergr/effects_test.clj).
Landed (step 2, first part): the vocabulary, perform!, receipts (reads by digest;
subject = the acting identity), the filter (admission and read-only), sandbox files
(physical and virtual), git and HTTP routed through it, receipts kept on the working context
(hardening 5). Next: the remaining capabilities (rooms, databases, processes, mail,
schedules, model calls), eval metering, then can?, replay and faults as answering handlers.
| Goal | Mode / handler | Notes |
|---|---|---|
| Read-only eval | :read-only | honest because every effect passes the boundary; pure computation still runs |
| Permissions shared with simmis | authority | the can? seam first, eacl later |
| Metering | resources | eval CPU/wall and model tokens as ledger resources, recorded not charged |
| Reproducible Attempts | :replay | an Attempt's log replays it; divergence is reported |
| Robustness benchmarks | :faults | an environment knob like wiki-gen's noise, reported per Scorecard |
| Approval before running | :preflight | a plan of effects; control flow that depends on results needs recorded or typed stand-ins; beichte's static purity analysis proves parts effect-free without running them |
| Claim-level citations | receipts | "a citation counts only if the Run read the document" is a receipt query |
| Training data | receipts | a trajectory is the tool calls plus their effects and results |
Spindel's spin macro already has effects (await, track). Performing sandbox effects as
spindel effects, with the handler chain as the handler, would make modes compose per spin and
per forked world: a fork can run under :faults or :replay while its parent runs :live.
This is a later step and a spindel PR for review; the boundary is designed so the handler
chain can move there without changing capabilities.
simmis's doc/tool-authorization.md already states the model this implements: a call runs
when dvergr admitted the tool to the Run, ReBAC admits the party on the object, Kontor admits
the resources, and the sandbox contains the implementation; delegation is explicit
attenuation (a grant to a Run is a subset of its parent's); every call has an authorization
receipt. The boundary is where those four checks meet for effects inside an eval, not only
for tool calls. The effect vocabulary and can? resource shapes are shared.
perform, the chain with admission,
containment (existing guards moved behind it), :live, receipts; capabilities routed
through it namespace by namespace, starting with network and files. Eval metering as a
recorded resource.can? seam over existing relations, cross-room effects
decided by it; :read-only.can?.:http/request,
:fs/read, :fs/write, :db/transact, :room/fork, :room/merge, :room/post,
:model/call, :process/run, :schedule/create, :mail/send, …), each with classes
from :read :write :network :egress :spend :process :lifecycle :schedule :global.can?). The room's owner, and MCP
connections within their selection, keep their reach.can?: post! takes its author from the acting identity and refuses
caller-supplied :from/:source-user…; update_agent_profile changes the acting
agent's own prompt only; dvergr.actors/dvergr.tasks writes are limited to the acting
agent and its rooms.Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |