mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 12:03:53 +00:00
- Fix security.mdx: move SDK/session setup from per-action to createVars to prevent resource leaks (new SDK + session was created on every submitPrompt call without cleanup) - Add onSleep cleanup hook matching multiplayer.mdx pattern - Update persist-rivet peer dependency from >=0.5.0 to >=2.0.0 (rivetkit is at 2.0.42) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
196 lines
5.1 KiB
Text
196 lines
5.1 KiB
Text
---
|
|
title: "Security"
|
|
description: "Backend-first auth and access control patterns."
|
|
icon: "shield"
|
|
---
|
|
|
|
As covered in [Architecture](/architecture), run the Sandbox Agent client on your backend, not in the browser.
|
|
|
|
This keeps sandbox credentials private and gives you one place for authz, rate limiting, and audit logging.
|
|
|
|
## Auth model
|
|
|
|
Implement auth however it fits your stack (sessions, JWT, API keys, etc.), but enforce it before any sandbox-bound request.
|
|
|
|
Minimum checks:
|
|
|
|
- Authenticate the caller.
|
|
- Authorize access to the target workspace/sandbox/session.
|
|
- Apply request rate limits and request logging.
|
|
|
|
## Examples
|
|
|
|
### Rivet
|
|
|
|
<CodeGroup>
|
|
|
|
```ts Actor (server)
|
|
import { UserError, actor } from "rivetkit";
|
|
import { SandboxAgent } from "sandbox-agent";
|
|
|
|
type ConnParams = {
|
|
accessToken: string;
|
|
};
|
|
|
|
type WorkspaceClaims = {
|
|
sub: string;
|
|
workspaceId: string;
|
|
role: "owner" | "member" | "viewer";
|
|
};
|
|
|
|
async function verifyWorkspaceToken(
|
|
token: string,
|
|
workspaceId: string,
|
|
): Promise<WorkspaceClaims | null> {
|
|
// Validate JWT/session token here, then enforce workspace scope.
|
|
// Return null when invalid/expired/not a member.
|
|
if (!token) return null;
|
|
return { sub: "user_123", workspaceId, role: "member" };
|
|
}
|
|
|
|
export const workspace = actor({
|
|
state: {
|
|
events: [] as Array<{ userId: string; prompt: string; createdAt: number }>,
|
|
},
|
|
|
|
createVars: async (c) => {
|
|
// Connect to Sandbox Agent from the actor (server-side only).
|
|
// Sandbox credentials never reach the client.
|
|
const sdk = await SandboxAgent.connect({
|
|
baseUrl: process.env.SANDBOX_URL!,
|
|
token: process.env.SANDBOX_TOKEN,
|
|
});
|
|
|
|
const session = await sdk.resumeOrCreateSession({ id: "default", agent: "claude" });
|
|
|
|
const unsubscribe = session.onEvent((event) => {
|
|
c.broadcast("session.event", {
|
|
eventIndex: event.eventIndex,
|
|
sender: event.sender,
|
|
payload: event.payload,
|
|
});
|
|
});
|
|
|
|
return { sdk, session, unsubscribe };
|
|
},
|
|
|
|
onBeforeConnect: async (c, params: ConnParams) => {
|
|
const claims = await verifyWorkspaceToken(params.accessToken, c.key[0]);
|
|
if (!claims) {
|
|
throw new UserError("Forbidden", { code: "forbidden" });
|
|
}
|
|
},
|
|
|
|
createConnState: async (c, params: ConnParams) => {
|
|
const claims = await verifyWorkspaceToken(params.accessToken, c.key[0]);
|
|
if (!claims) {
|
|
throw new UserError("Forbidden", { code: "forbidden" });
|
|
}
|
|
|
|
return {
|
|
userId: claims.sub,
|
|
role: claims.role,
|
|
workspaceId: claims.workspaceId,
|
|
};
|
|
},
|
|
|
|
actions: {
|
|
submitPrompt: async (c, prompt: string) => {
|
|
if (!c.conn) {
|
|
throw new UserError("Connection required", { code: "connection_required" });
|
|
}
|
|
|
|
if (c.conn.state.role === "viewer") {
|
|
throw new UserError("Insufficient permissions", { code: "forbidden" });
|
|
}
|
|
|
|
const result = await c.vars.session.prompt([
|
|
{ type: "text", text: prompt },
|
|
]);
|
|
|
|
c.state.events.push({
|
|
userId: c.conn.state.userId,
|
|
prompt,
|
|
createdAt: Date.now(),
|
|
});
|
|
|
|
return { stopReason: result.stopReason };
|
|
},
|
|
},
|
|
|
|
onSleep: async (c) => {
|
|
c.vars.unsubscribe?.();
|
|
await c.vars.sdk.dispose();
|
|
},
|
|
});
|
|
```
|
|
|
|
```ts Client (browser)
|
|
import { createClient } from "rivetkit/client";
|
|
import type { registry } from "./actors";
|
|
|
|
const client = createClient<typeof registry>({
|
|
endpoint: process.env.NEXT_PUBLIC_RIVET_ENDPOINT!,
|
|
});
|
|
|
|
const handle = client.workspace.getOrCreate(["ws_123"], {
|
|
params: { accessToken: userJwt },
|
|
});
|
|
|
|
const conn = handle.connect();
|
|
|
|
conn.on("session.event", (event) => {
|
|
console.log(event.sender, event.payload);
|
|
});
|
|
|
|
const result = await conn.submitPrompt("Plan a refactor for auth middleware.");
|
|
console.log(result.stopReason);
|
|
```
|
|
|
|
</CodeGroup>
|
|
|
|
Use [onBeforeConnect](https://rivet.dev/docs/actors/authentication), [connection params](https://rivet.dev/docs/actors/connections), and [actor keys](https://rivet.dev/docs/actors/keys) together so each actor enforces auth per workspace.
|
|
|
|
### Hono
|
|
|
|
```ts
|
|
import { Hono } from "hono";
|
|
import { bearerAuth } from "hono/bearer-auth";
|
|
|
|
const app = new Hono();
|
|
|
|
app.use("/sandbox/*", bearerAuth({ token: process.env.APP_API_TOKEN! }));
|
|
|
|
app.all("/sandbox/*", async (c) => {
|
|
const incoming = new URL(c.req.url);
|
|
const upstreamUrl = new URL(process.env.SANDBOX_URL!);
|
|
upstreamUrl.pathname = incoming.pathname.replace(/^\/sandbox/, "/v1");
|
|
upstreamUrl.search = incoming.search;
|
|
|
|
const headers = new Headers();
|
|
headers.set("authorization", `Bearer ${process.env.SANDBOX_TOKEN ?? ""}`);
|
|
|
|
const accept = c.req.header("accept");
|
|
if (accept) headers.set("accept", accept);
|
|
|
|
const contentType = c.req.header("content-type");
|
|
if (contentType) headers.set("content-type", contentType);
|
|
|
|
const body =
|
|
c.req.method === "POST" || c.req.method === "PUT" || c.req.method === "PATCH"
|
|
? await c.req.text()
|
|
: undefined;
|
|
|
|
const upstream = await fetch(upstreamUrl, {
|
|
method: c.req.method,
|
|
headers,
|
|
body,
|
|
});
|
|
|
|
return new Response(upstream.body, {
|
|
status: upstream.status,
|
|
headers: upstream.headers,
|
|
});
|
|
});
|
|
```
|
|
|