Guides
Isolation and untrusted content
Part of the Loom User Guide, section 17 (Loom 2.0 kernel hardening). What may cross a boundary: data-plane isolation and egress (K7), the untrusted-content integrity axis (ADR 0085), and images, audio, files and real tool turns (R5).
Data-plane isolation & egress (K7)
K2’s grant table governs which effects run; in-process tool bodies still hold ambient
authority. @loom/isolate turns the convention into a boundary — the tool runs in a pooled
child process at the effect position (innermost, so grants/metering/safety all stay on
the kernel side where the isolate can’t forge them):
import { IsolatedToolEffect } from "@loom/isolate";
effectRuntime.register("fetch-metrics", new IsolatedToolEffect({
id: "fetch-metrics",
modulePath: require.resolve("./tools/fetch-metrics.cjs"), // code the CHILD loads — no closures
egress: ["prometheus.internal:9090"], // K7.3 — static allowlist (tool$({ egress }) mirrors it)
env: { PROM_TOKEN: secrets.prom }, // K7.4 — this tool's credentials, and ONLY this tool's
timeoutMs: 30_000, // non-cooperative SIGKILL — the real kill path
idempotent: true, // K9 semantics compose unchanged
}));
// tools/fetch-metrics.cjs — authored against the marshaled ctx shim:
module.exports = async (input, ctx) => {
const res = await ctx.fetch(`http://prometheus.internal:9090/api/v1/query?...`);
ctx.emit("METRICS_SAMPLED", { at: input.at }); // forwarded, ordered, before the result
return JSON.parse(res.body);
};
The child’s environment is scrubbed (exactly env + PATH — the parent’s secrets aren’t
there to read), no store handles cross the boundary, and all network goes through the
brokered ctx.fetch: the allowlist is enforced before any connection (durable
EGRESS_DENIED on refusal, folded into state.variables._egress) and requests dispatch
resolve-and-pin (DNS-rebind safe). The runner also blocks ambient network modules
(require("http") throws) as defense-in-depth — for hostile-code-grade confinement add
OS-layer sandboxing (netns/seccomp/container egress) around the worker.
This boundary is for tool code you ship, not for code a model wrote. Model-written scripts run through
sandboxEval$ in a capability-empty engine: QuickJS-in-WASM by default (@loom/plugin-sandbox) or an
isolation boundary you bring behind the sandbox backend SPI (@loom/sandbox,
ADR 0144).
Untrusted content: the integrity axis (ADR 0085)
K7 governs what leaves. The opposite direction — what may enter an instruction position — needs a
different mechanism, because integrity is a property of a value, not a field: the same
summary: string is trustworthy when your flow computed it and untrusted when a third-party MCP
server returned it, and which one it is is only knowable at runtime.
So taint is attached to the value, at ingress, by whoever acquired it:
import { tainted, unwrapTainted } from "@loom/classify";
const doc = tainted(await fetchThirdPartyDoc(), "mcp:acme/fetch_doc");
The wrapper is plain JSON, so it survives the log verbatim and a replay hands back a value that is
still tainted. @loom/llm’s guardRequest then runs inside every createLlmEffect,
unconditionally: tainted content may appear only in a user message, and only fenced as
<untrusted-input origin="…">. A system message, an assistant turn, or a tool name/description
throws UntrustedContentError.
The producers ship opt-in: registerMcpInvoke(client, runtime, { taintResults: true }),
client.registerInto(effects, { taintResults: true }), mcpTool$(client, name, { taintResults: true }) and
new A2APeerClient({ url, taintResults: true }). Data classification lists what they do not cover. The refusal is structural; the fence is only
a label — put UNTRUSTED_CONTENT_SYSTEM_NOTE in your system prompt so the model knows what the tag
means. Full treatment in data classification.
Images, audio, files and real tool turns (R5, ADR 0120)
A message’s content is still a string (text only, unchanged) or now an array of parts: text,
image / audio / file (each with a mediaType and a source), tool_use (the model’s call) and
tool_result (the answer). agentLoop$ and AgentRunner thread real tool turns: the assistant message
carries tool_use parts, and ONE user message of tool_result parts answers them by id (a refusal or a
failing tool is isError). There is no tool role: each provider maps the one canonical shape to its own
(Anthropic content blocks, OpenAI tool_calls + role: "tool", Gemini functionCall / functionResponse).
import { mediaResult } from "@loom/agent";
// A tool hands the model an image by CLAIM-CHECK: store the bytes, return the address. The tool must be
// declared by the operator — `tool$({ ..., returnsMedia: true })` — or its result is just data.
const ref = await contentStore.put(png);
return mediaResult([
{ type: "text", text: "latency by region" },
{ type: "image", mediaType: "image/png", source: { kind: "ref", ref: { address: ref.__loomContent, size: png.length } } },
]);
// Resolve the address to bytes at call time — the journal keeps only the address. The resolver MUST
// authorize: the store is content-addressed with no tenancy, so check that this execution may read it,
// and DENY when `executionId` / `principal` is missing.
effects.register(
"model",
createAgentModelEffect(new AnthropicProvider(), {
resolveContent: async (r, { executionId, workflowId, principal, maxBytes }) => {
if (!(await mayRead(principal, executionId, r.address))) return null;
// Pass the asking execution on: a store that seals per subject (EncryptingContentStore, ADR 0143)
// needs it to pick the key.
return contentStore.get(
{ __loomContent: r.address },
workflowId === undefined ? undefined : { executionId, workflowId }
);
},
})
);
Inline { kind: "data" } media is capped at 64 KiB per part and 256 KiB per request so the durable log can
never carry real media; over the cap is refused (a tool’s oversized inline result becomes an isError result
the model can recover from). If a tool’s ref cannot be resolved (erased, over the 32 MiB budget, a size that
differs from the ref’s declared one), the model sees an isError result instead of the run failing; the same
failure for a ref your own code put in a message fails the call.
A provider declares which modalities it takes (capabilities). The shipped ones declare media only for
known media-capable model families; an unknown model id, an older one (plain gpt-4) or a non-default
baseUrl (an OpenAI-compatible server) is text-only until you pass capabilities: { image: ["image/png"] },
and capabilities: {} makes any model text-only; anything else throws
UnsupportedModalityError before a network call, and so does a ref with no resolveContent. A TAINTED tool
output is never interpreted as media (it stays fenced text), and an untrusted media part you build yourself
(untrusted: { origin }) may not name a stored ref; it is bracketed by the same <untrusted-input> fence as
text and refused outside a user message. onToolDelta / onToolReset stream a tool call’s input as it is
generated; on a retry onToolReset fires so you discard the failed attempt’s fragments (this channel is
outside the delta validators).
Warning: never read
message.contentraw.LlmMessage.contentisstring | LlmContentPart[], andmsgs.map((m) => m.content).join(" "),`${m.content}`and{ text: m.content }handed to an untypedJSON.stringifyall still type-check, then turn a parts message into"[object Object]"or a JSON array. Read it withtextOf(content)(text) ortoParts(content)(structure), or branch ontypeof content === "string". Provider-author checklist: (1) read content only throughtextOf/toParts/ atypeofbranch; (2) map every part type you declare incapabilities, and refuse the rest; (3) runrunLlmProviderConformancefrom@loom/conformance, whose parts case fails a provider that string-coerces a parts message. Loom’s own source is held to this by a type-aware fitness test (packages/spec/src/rawMessageContent.test.ts); a third-party package has the conformance case and this checklist.
Back to User Guide section 17.
Source: docs/user-guide/isolation-and-untrusted-content.md in the loom repository.