Sandbox enforcement (v0.5)¶
Status — implemented for v0.5 dogfood. Gaps 4 and 5 in
DEMOS_V0_4_NOTES.mdare closed by this document.
Mighty ships two complementary enforcement mechanisms for the
sandbox block:
- CPU / memory budgets — caught inside the MtyIR interpreter as
typed
RunResultvariants. - Capability allow-lists — enforced at the host bridge between
the MtyIR interpreter and
mty-stdlib, via a process-wide default cap installed by the driver.
The two share the same source of truth (BudgetTracker on the
runtime side, parsed from the agent's sandbox manifest) but route
through different code paths because they trigger at different
times.
Budget charging — CPU steps + memory¶
mty-sir::interp::run::Interp tracks two scalars:
budget: u64— remaining "steps". Every executed statement + terminator decrements it. When it hits 0 the interpreter returns [RunResult::BudgetExceeded].mem_used: u64/mem_budget: u64— total bytes "charged" so far + the per-run ceiling. Set viarun_fn_with_resource_budget(..., step_budget, mem_budget).mem_budget == 0is the legacy "no cap" sentinel — pre-v0.5 callers (run_fn_with_budget) hit this path implicitly.
The interpreter charges memory at every "allocation-shape" rvalue evaluation:
Rvalue::AdtInit(struct / enum) — 24 B header + sum of field byte estimatesRvalue::TupleInit— 16 B header + sum of element estimatesRvalue::ArrayInit— 24 B header + sum of element estimates
estimate_value_bytes is intentionally approximate (matches the
shape of size_of::<Value> on 64-bit hosts plus inline string
payloads). The contract is "deterministic + monotonic", not
"bit-perfect".
When mem_used > mem_budget after a charge:
charge_memreturns anErr(("MT5009", message))which theeval_rvaluepath lifts into [EvalOutcome::Trap].- The interpreter's main loop catches the next step boundary,
notices
mem_used > mem_budget, and returns [RunResult::MemBudgetExceeded { used, limit }]. - Callers map the variant onto exit code
4and an MT5009 trap message. Seecrates/mty-driver/src/pipeline.rs::run_file_with_runtime.
Capability allow-lists — fs.read / fs.write¶
mty-stdlib::fs::FsCap is the in-process representation of a
capability value. It carries an allowed: Vec<PathBuf> list; an
empty list is "unrestricted" (tests + un-sandboxed CLI runs).
FsCap::check(path) succeeds if any allowed prefix matches the
incoming path; otherwise it returns
[IoErr::Forbidden(path.display())].
Process-wide default cap¶
The stdlib's host::dispatch is invoked from the MtyIR interpreter's
EffectOp::GenericCall path without an explicit cap value (today's
MtyIR lowerer doesn't materialise per-call caps from the sandbox
manifest yet — that's the v0.6 work). To still enforce the
manifest's fs.read = [...] allow-list, the dispatcher consults a
process-wide default cap:
use sdust_stdlib::fs::{install_default_read_cap, FsCap};
// Driver setup — called once per sandboxed run:
install_default_read_cap(FsCap::rooted(["./inputs", "./models"]));
install_default_write_cap(FsCap::rooted(["./outputs"]));
Subsequent std.fs.read("./outside") calls land in
host::fs_read, which clones the current cap via
current_default_read_cap() and short-circuits to
IoErr::Forbidden(...). The Mighty-visible result is a
[Result::Err(forbidden:<path>)] variant — agents can pattern-match
on it just like any other failure.
Why the global slot?¶
The MtyIR dispatcher signature is (path, method, args) -> Value. It
has no &mut Interp and no way to thread per-agent state through. A
global slot lets the driver install the cap once at agent startup
without touching the MtyIR shape.
When the v0.6 lowerer materialises per-call caps, this global slot becomes the fallback path: if the call shape carries an explicit cap, the host uses that; otherwise it falls through to the default cap.
Tests¶
crates/mty-sir/tests/budget_charges.rs— CPU + mem trips.crates/mty-stdlib/tests/fs_capability_allowlist.rs— Forbidden surface + install/restore roundtrip.
v0.6 follow-ups¶
- Lower
sandboxmanifest'sfs.read = [...]into a MtyIRRvalue::CapValue { family: Fs, constraint: PathList(...) }and thread it through everystd.fs.*call. - Replace the heuristic
estimate_value_byteswith a proper arena allocator that the runtime sizes per agent. - Extend the cap surface to
net(host allowlist already exists inBudgetTracker::check_host; needs the sameinstall_default_*_capwiring insidehost::http_get/http_post).