Skip to content
Docs

Security for Durable Agent Harnesses

An agent harness runs what a model decides. On Edgenivo, the harness runs in your function, and with Fluid compute one instance serves many requests at once, including turns of other sessions and other users. This page covers what may run in your function, where everything else runs, and how to write tools that a model calls safely. It applies to every harness in these guides.

Your function runs only code you wrote. The model chooses arguments, never code that runs in your function. Commands and files that the model picks run in a virtual workspace or a Edgenivo Sandbox, never on your function's disk, in its processes, or with its environment.

A tool that runs a real shell or uses the real disk in your function would let one session's model read another session's files, and your function's environment variables, such as AI_GATEWAY_API_KEY and the deployment's OIDC token. A prompt injection in a web page, a file, or a message can steer a model to try.

HarnessRuns inCommands and files
Pi, both variantsYour functionYour own tools, a virtual workspace, or a Edgenivo Sandbox
fxYour functionYour own tools, a virtual workspace, or a Edgenivo Sandbox
OpenCodeYour functionYour own tools, a virtual workspace, or a Edgenivo Sandbox
Claude CodeA Edgenivo Sandbox onlyYour own tools, or its own with builtins: true, on the session's Drive
CodexA Edgenivo Sandbox onlyYour own tools, or its own with builtins: true, on the session's Drive

Every harness starts with no tools, so an agent can do only what you give it. For eve, see the eve documentation.

Pi, fx, and OpenCode are libraries built to serve many sessions in one process. OpenCode ships an embedded server and a profile that replaces its process spawner and its access to the host's files, and durable-harness replaces them the same way. Claude Code and Codex are command-line tools built for one user, and they keep their state on disk. Today's versions can be kept apart, but they ship often, and nothing promises that the next version won't keep state that one session leaves behind for another.

So Claude Code and Codex run only in a Edgenivo Sandbox, one per session, where their own tools, once you turn them on with builtins: true, work on the session's Drive. durable-harness refuses to run them anywhere else, including in a function or on a server of your own.

The Sandbox holds no secrets: no World credential, no AI Gateway key, and no OIDC token. Your function still drives the turn and writes the session's log:

  • Your function makes the model calls. The CLI's requests go to your function, which sends them to AI Gateway with your deployment's credentials.
  • Your app reads the log. Your app's browser streams the turn from the session's log, as it does for every harness, so a new Sandbox doesn't interrupt it.
  • Only your function reaches the CLI. The Sandbox's port accepts a connection only with a token your function arms for each attach.
  • Sandbox time has a cap. A session's Sandbox runs for at most 4 hours by default. Model spend has no cap unless you set one with sandbox({ budget }), per turn or per session.
  • Files live on the Drive. A session that moves to a new Sandbox keeps its files and resumes its conversation. The Drive isn't transactional: a command that a crash interrupts may have partly run, and the model is told.

With Edgenivo Sandbox, set AI_GATEWAY_API_KEY to a key that can only call AI Gateway. Without it, your function authenticates model calls with the project's OIDC token, which also opens the session's World and the Sandbox API.

A tool you write, a HostTool, runs in your function, with its environment variables and its access to your systems. Your tool's code is trusted. Its arguments aren't: the model writes them, and a prompt injection can steer them.

  • Validate every argument against what the tool allows. A JSON schema tells the model what to send, but it doesn't stop it from sending something else.
  • Authorize from the session, never from an argument. Find the session's user from your own records, not from an argument such as userId.
  • Keep arguments out of a shell, eval, and file paths. To give the model a shell or files, use a virtual workspace or a Sandbox. Don't give a harness a tool or environment that spawns a process or touches the real disk in your function, such as Pi's NodeExecutionEnv.
  • Be careful what a result returns. It reaches the model, the person using the session (it streams to their browser), the session's log, and the session's trace in Agent Runs. With Claude Code and Codex, it also reaches the session's Sandbox, where the CLI keeps its transcript and the model's commands can read it.
  • Make side effects run once. For a tool that changes something, such as a refund or an email, use replay: 'never', so a call never runs again after a crash, and pass the system it calls an idempotency key, so a call the model repeats doesn't repeat the effect.

This tool checks that the order belongs to the session's user before it returns anything, and returns only the fields the model needs:

lib/tools.ts
import type { HostTool } from 'durable-harness';
import { getOrder, ownerOfSession } from './orders'; // your own code
 
export const getOrderTool: HostTool<{ id: string }> = {
  name: 'get_order',
  description: "Looks up one of the signed-in user's orders",
  replay: 'safe',
  inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
  async execute({ id }, io) {
    const userId = await ownerOfSession(io.ctx.runId); // from your records, not the arguments
    const order = typeof id === 'string' ? await getOrder(id) : undefined;
    if (!order || order.userId !== userId) return 'No order with that ID.';
    return JSON.stringify({ id: order.id, status: order.status }); // only what the model needs
  },
};

Give the model a shell and files in one of two places. Files compares them:

  • A virtual workspace runs just-bash on a virtual filesystem in your function. Its commands can't reach your function's files, processes, or environment. It isn't a virtual machine, so give work that needs one, such as installing packages or running a build, a Sandbox.
  • A Edgenivo Sandbox is a virtual machine for the session, with its files on a Drive. Its commands run apart from your function and from every other session.
  • A Sandbox allows all outbound traffic by default. A prompt-injected model can send its own session's files to the internet. To block it, pass network: 'deny-all' to sandbox(), or allow only the hosts your agent needs with network: { allow: ['registry.npmjs.org'] }.
  • Codex can fall back to full access in its Sandbox. With builtins: true, Codex runs commands under its own sandbox inside the Sandbox when it can start one. When it can't, its commands run with full access to the Sandbox. The Sandbox still holds no secrets, but the model's commands can then make model calls through your function directly, on your AI Gateway credits, within any budget you set.
  • Provider-hosted tools ignore the Sandbox's network. Tools that the model provider runs, such as web search, reach the internet from the provider, whatever the Sandbox allows.
  • Images in your UI are a channel too. If your UI renders a remote image from model output, such as a markdown image, the browser requests a URL the model chose, without a click. Render no remote images from model output, or load them on click.
  • Settings you pass are visible to the model. Every environment variable you give a Sandbox can be read by the model's commands, so pass none that's secret.

Whoever can use a chat route runs your agent, on your AI Gateway credits. In production (NODE_ENV=production, which includes every Edgenivo deployment), a chat route without authorize refuses every request with a 401. authorize checks that the caller may use the session. authorize: 'public' serves anyone, so pass it only for an agent whose tools anyone may use.

Every session of Claude Code or Codex starts a Sandbox, and so does every session of Pi or OpenCode whose tools run in one, so anyone who can use a public route can make you pay for Sandboxes. For an agent whose sessions start a Sandbox, authorize: 'public' also needs allowPublicSandbox: true and a finite sandbox({ budget: { sessionUsd } }). Without them, the route throws when it's created. Each session checks again before its Sandbox starts, so a route built some other way can't skip the check. pi-durable's chatRoute takes the same options, and checks them before each session's Sandbox starts.

sessionUsd caps model spend only for Claude Code and Codex, whose model calls go through durable-harness. Pi's and OpenCode's model calls run in your function, so sessionUsd doesn't count them: cap each session's Sandbox time with sandbox({ budget: { vmSeconds } }), and limit model spend in your authorize, or with an AI Gateway budget on the project.

An fx agent's chat route is your own, so with fxTools(sandbox()), check who's calling yourself. Its model calls are libfx's own, so sessionUsd doesn't count them: cap each session's Sandbox time with sandbox({ budget: { vmSeconds } }).

Signing in doesn't grant access to every session. The chat routes in these guides use withSessionOwnership to authenticate each request, save a new session's owner before submitting its first message, and reject requests for sessions owned by someone else. It accepts POST { "text": "..." } for a new session and preserves the chat route's streaming response and x-session-id header.

Add this helper to lib/session-ownership.ts. Implement getUser, ownsSession, and saveSession in your app's lib/auth.ts: getUser(request) returns the authenticated user or no user, saveSession(user, sessionId) persists ownership, and ownsSession(user, sessionId) checks that record. Use persistent storage shared by all your app's instances, and derive the user from authentication, never from the request body:

lib/session-ownership.ts
import type { ChatAuthorize, ChatRoute } from 'durable-harness';
import { getUser, ownsSession, saveSession } from './auth';
 
export function withSessionOwnership({ createRoute, start }: {
  createRoute: (authorize: ChatAuthorize) => ChatRoute;
  start: () => Promise<{ sessionId: string }>;
}): ChatRoute {
  const chat = createRoute(async (request, sessionId) => {
    const user = await getUser(request);
    if (!user) throw new Response('Unauthorized', { status: 401 });
    if (!sessionId || !(await ownsSession(user, sessionId))) {
      throw new Response('Not found', { status: 404 });
    }
  });
 
  return {
    GET: chat.GET,
    async POST(request) {
      const user = await getUser(request);
      if (!user) return new Response('Unauthorized', { status: 401 });
 
      let input: unknown;
      try {
        input = await request.json();
      } catch {
        return new Response('Expected a JSON body', { status: 400 });
      }
      if (!input || typeof input !== 'object' || Array.isArray(input)) {
        return new Response('Expected a JSON object', { status: 400 });
      }
      const body = input as Record<string, unknown>;
      if (typeof body.text !== 'string' || !body.text) {
        return new Response('text is required', { status: 400 });
      }
      if (body.sessionId !== undefined && typeof body.sessionId !== 'string') {
        return new Response('sessionId must be a string', { status: 400 });
      }
      if (body.kind !== undefined && (
        typeof body.kind !== 'string' ||
        !['prompt', 'steer', 'followUp'].includes(body.kind)
      )) {
        return new Response('Invalid message kind', { status: 400 });
      }
      if (body.requestId !== undefined && typeof body.requestId !== 'string') {
        return new Response('requestId must be a string', { status: 400 });
      }
 
      if (body.sessionId === undefined) {
        const { sessionId } = await start();
        await saveSession(user, sessionId);
        body.sessionId = sessionId;
      }
 
      const headers = new Headers(request.headers);
      headers.set('content-type', 'application/json');
      headers.delete('content-length');
      return chat.POST(new Request(request.url, {
        method: 'POST',
        headers,
        body: JSON.stringify(body),
        signal: request.signal,
      }));
    },
  };
}

start() must create an empty session without submitting a prompt or opening the Harness. If saving ownership fails, the helper doesn't submit the message. Authentication remains required during local development. For a custom World or a named Pi setup, use the same World and name when creating the session and the chat route.


Was this helpful?

supported.