Appearance
Draw the trust boundaries
A model wrote a short script to total a table. The script worked, but it ran in the page's JavaScript realm. That meant it could also read IndexedDB, inspect the live application, call fetch, and use every credential available to the page. The code looked like a calculator. Its actual authority matched the signed-in user.
The fix was architectural. Model-written code moved into an interpreter with no ambient browser APIs, inside a worker with a termination backstop. The host passed a data snapshot in and accepted structured data out. A prompt asking the model to "be careful" would not have changed the script's authority.
Map authority before features
text
trusted control code
/ | \
/ | \
model output server secrets database
| | |
v v v
sandboxed code tool adapters policy rows
| |
structured data approvals
\ /
\ /
visible resultEach arrow should carry a named data shape. Any arrow that carries a live object, a broad token, or an unbounded string deserves scrutiny.
Working vocabulary
- Trust boundary: A point where data or control moves between components with different authority.
- Ambient authority: Capabilities available without being passed explicitly, such as browser storage or process environment variables.
- Capability: A narrow handle that permits one action on one resource.
- Consequential action: A write that communicates, spends, publishes, deletes, or changes another system.
- Structured clone: A copied data value with no shared functions or live object references.
- Server-only state: Data that never enters the browser synchronization schema.
Classify every input twice
First classify by origin. A user's request, a model response, a web page, and a webhook are different sources.
Then classify by authority. A value may be untrusted text yet still trigger expensive work. Another may come from a trusted database but contain content copied from an email.
This matters because agents mix instructions and data in one textual context. A web page can contain "ignore previous instructions." Labelling it as untrusted data helps the model, but the enforceable control is elsewhere. A tool wrapper must still require approval before sending an email or posting a public comment.
Use a small action policy:
ts
type Effect = "read" | "write" | "consequential";
type ToolPolicy = {
effect: Effect;
allowedInPrivate: boolean;
allowedWithoutUserPresent: boolean;
};
function mayExecute(
policy: ToolPolicy,
ctx: { approved: boolean; privateMode: boolean; interactive: boolean }
) {
if (ctx.privateMode && !policy.allowedInPrivate) return false;
if (!ctx.interactive && !policy.allowedWithoutUserPresent) return false;
if (policy.effect === "consequential" && !ctx.approved) return false;
return true;
}The model can request an action. It cannot waive the policy.
Sandboxing model-written code
Same-realm eval and new Function are not acceptable. A plain worker provides a kill switch, but a same-origin worker may still reach network and storage. A sandboxed iframe removes origin authority, but a tight loop can keep its thread busy. Neither alone gives a memory cap.
An embedded JavaScript interpreter offers a better default for short data tasks:
- no DOM, network, storage, timers, or console unless the host installs them;
- a memory limit on the interpreter heap;
- a stack limit;
- an interrupt checked during bytecode execution;
- a worker termination timer outside the interpreter.
The host API should be smaller than feels convenient:
ts
type SandboxRequest = {
code: string;
messages: Array<{ role: "user" | "assistant"; content: string }>;
deadlineMs: number;
};
type SandboxResult =
| { status: "done"; stdout: string[]; value: unknown }
| { status: "killed" | "error"; stdout: string[]; error: string };Pass the message snapshot as serialized data. Do not hand the guest a callback that can navigate application state. If output must stream, let print post strings to the host. Keep every other result JSON-compatible.
Use three timeout layers. The interpreter interrupt stops ordinary bytecode. A worker-side timer handles a guest waiting forever on a promise. A main-thread timer terminates the worker if either mechanism fails. Memory exhaustion should also replace the worker because WebAssembly memory generally does not shrink.
Keep secrets out of synchronized data
Local-first systems make browser reads fast by synchronizing tables. That convenience is dangerous when developers add a token column to an existing synced table.
Maintain an explicit list of server-only tables for:
- provider credentials and refresh tokens;
- webhook secrets;
- push subscription keys;
- work queues and raw inbound events;
- private analytics records.
Expose a separate public projection when the UI needs status. "Connected as account@example.test" is safe to sync. The encrypted refresh token is not.
Encryption at rest is useful, but it does not repair a bad boundary. If the decryption key sits in the same browser that receives the ciphertext, the separation is cosmetic. Decrypt credentials only inside the server-side adapter that calls the provider.
Private mode needs mechanical rules
A private conversation cannot rely on a line in the system prompt. It needs tool and data rules:
- omit memory-write, scheduling, goal, and delivery tools;
- exclude the conversation from retrieval indexes;
- skip durable agent analytics;
- block outbound channel delivery;
- expire the row according to its policy and let foreign-key cascades remove children.
It may still read long-term memory if the product defines private mode as "do not retain this conversation." A blank-slate mode is a different feature. Name the distinction because users will assume one or the other.
Decisions, including the uncomfortable ones
Use explicit approval for consequential actions. A broad "always allow" switch is tempting. It turns prompt injection into account authority. Keep approval bound to a hash of the exact tool input and expire it quickly.
Treat read tools as lower risk, not harmless. Reads can leak private data into a reply or incur large costs. Scope credentials and bound output even when approval is unnecessary.
Prefer a small interpreter to a full container for browser-local work. Containers give stronger process isolation but require server execution, scheduling, and data transfer. For short local calculations, a memory-limited interpreter has a better cost and privacy profile. It does not protect against every runtime bug, so hostile multi-tenant execution needs a stronger boundary.
Do not give sandboxed code network access by default. An allowlist often grows into a proxy around the same risk. Fetch data through audited host tools, then pass a snapshot to the guest.
Where designs break
A tool can return an object containing a live client or function. The model never sees that object directly, but later host code may invoke it with broader authority. Normalize tool output to plain data at the boundary.
An approval can authorize different input if the hash covers only a summary. Hash the canonical full argument shape. Show the human a readable summary, but compare the exact canonical input.
A webhook may have a valid signature and a malicious payload. Authentication proves who sent it, not that its text is an instruction. Store and label the payload as data.
An isolated scheduled turn has no user watching. Remove tools that require an open browser or immediate confirmation. Failing later with "no browser claimed the task" wastes time and obscures the policy.
Boundary review checklist
The surrounding system from The harness is the product now has enforceable limits. The next problem is temporal: what belongs to one turn when messages arrive, tools run, and retries overlap? Continue with Design the turn, not the demo.