# Security model

> App runtime vs sandbox trust boundaries, secret brokering, connection token handling, channel signature verification, and fail-closed route auth defaults.

- Repository: vercel/eve
- GitHub: https://github.com/vercel/eve
- Human docs: https://www.grok-wiki.com/public/docs/vercel-eve-759e1d74a10f
- Complete Markdown: https://www.grok-wiki.com/public/docs/vercel-eve-759e1d74a10f/llms-full.txt

## Source Files

- `docs/concepts/security-model.md`
- `docs/guides/auth-and-route-protection.md`
- `packages/eve/src/public/channels/index.ts`
- `packages/eve/src/public/connections/index.ts`
- `packages/eve/src/execution/runtime-context.ts`

---

---
title: "Security model"
description: "App runtime vs sandbox trust boundaries, secret brokering, connection token handling, channel signature verification, and fail-closed route auth defaults."
---

Eve agents run across two trust zones — the **app runtime** (trusted Node.js host with `process.env`, connections, and durable execution) and the **sandbox** (isolated `/workspace` where shell commands execute). Secrets, route auth, connection tokens, and webhook signatures are resolved and verified on the trusted side; the model sees only tool definitions and their return values.

```mermaid
flowchart TB
  subgraph ingress["Ingress (channels)"]
    HTTP["eveChannel / defineChannel"]
    Webhooks["Slack · GitHub · Telegram · Twilio"]
  end

  subgraph trusted["App runtime (trusted)"]
    RouteAuth["routeAuth walk"]
    Tools["defineTool execute()"]
    Connections["MCP / OpenAPI connections"]
    TokenCache["Per-step token cache"]
    Proxy["bash · read_file · write_file proxy"]
  end

  subgraph isolated["Sandbox (untrusted)"]
    Workspace["/workspace filesystem"]
    Shell["Shell commands only"]
  end

  HTTP --> RouteAuth
  Webhooks --> RouteAuth
  RouteAuth --> Tools
  RouteAuth --> Connections
  Tools --> TokenCache
  Connections --> TokenCache
  Proxy --> Shell
  Proxy --> Workspace
  Tools -.->|"secrets never cross"| Shell
```

## Trust boundaries

| Capability | App runtime | Sandbox |
| --- | --- | --- |
| `process.env` / secrets | Yes | No |
| Author Node.js code (`defineTool`, connections, state) | Yes | No |
| Network | Unrestricted (host policy) | Controlled by `SandboxNetworkPolicy` |
| Filesystem | App-owned paths | Isolated `/workspace` |

On Vercel, the app runtime is a Vercel Function; each sandbox is a [Vercel Sandbox](https://vercel.com/docs/sandbox) microVM with hardware-level isolation. On local backends, isolation strength depends on the chosen `defineSandbox` backend (Docker, microsandbox, just-bash).

Built-in `bash`, `read_file`, `write_file`, `glob`, and `grep` tools are implemented in the app runtime and **proxy** into the sandbox. When the model calls `write_file`, the handler runs in the trusted host and forwards the write to `/workspace`. When it calls a custom `charge_card` tool, `execute` runs in the app runtime, reads `process.env.STRIPE_KEY`, calls Stripe, and returns `{ ok: true }` — the key never enters the sandbox or the model transcript.

<Warning>
Never pass secrets into sandbox environment variables or workspace files. Route privileged network calls through `defineTool`, `defineMcpClientConnection`, or credential brokering.
</Warning>

## Credential brokering

When the model needs authenticated egress from the sandbox — for example `git clone` of a private repo or an authenticated `curl` — and no tool or connection covers the call, Eve can inject auth headers at the sandbox **network firewall** so the secret stays in the app runtime.

On the Vercel Sandbox backend, call `sandbox.setNetworkPolicy()` with per-domain `transform` rules that add headers at the fetch boundary:

```ts title="Per-domain header injection"
const sandbox = await ctx.getSandbox();
await sandbox.setNetworkPolicy({
  allow: {
    "github.com": [{ transform: [{ headers: { authorization: "Basic ..." } }] }],
    "*": [],
  },
});
```

The sandbox process sees only the HTTP response; the bearer never appears in command output or workspace files. The GitHub channel's checkout flow uses this pattern to broker an installation token onto `github.com` and `codeload.github.com` egress.

| Backend | `setNetworkPolicy` support |
| --- | --- |
| `vercel()` | Full policy: allow-lists, deny-all, header transforms |
| `microsandbox()` | Local broker path with Vercel-style transforms |
| `docker()` | Coarse `"allow-all"` / `"deny-all"` only |
| `just-bash()` | Rejects `setNetworkPolicy` — no brokering surface |

See [Sandbox](/sandbox) for backend selection and policy authoring.

## Connection and tool credentials

Connection and tool auth is **outbound**: it signs the agent into external services (OAuth MCP servers, REST APIs) after route auth has already admitted the inbound caller.

### Token resolution

Connections declare `auth` on `defineMcpClientConnection` / `defineOpenAPIConnection`; individual tools can declare `auth` on `defineTool`. At runtime, `ctx.getToken()` and `ctx.requireAuth()` resolve the bearer:

<ParamField body="getToken()" type="Promise&lt;TokenResult&gt;">
Checks the per-step token cache, then invokes the authored `getToken` callback. With an interactive strategy (for example `connect("oauth/linear")` from `@vercel/connect/eve`), a cache miss parks the turn on a framework-owned OAuth callback URL.
</ParamField>

<ParamField body="requireAuth()" type="never">
Throws `ConnectionAuthorizationRequiredError` to trigger the same consent flow before any outbound call runs.
</ParamField>

Calling either accessor on a tool that does not declare `auth` throws at runtime.

### Per-step cache, never durable

Tokens are cached per workflow step, keyed by `(scope, principalKey)` so concurrent users on one session never share a bearer. The cache is a virtual context slot — **not serialized into durable workflow state**:

- Scoped by connection name or tool path-derived name
- Partitioned by resolved principal (`user:${issuer}:${id}` vs app-scoped strategies)
- Wiped between steps; cross-step reuse is delegated to the upstream provider (for example Connect's server-side cache)
- Evicted on downstream `401` so re-authorization does not re-read a stale token

The model never receives connection tokens. Tool `execute` and MCP client code inject them into outbound requests on the trusted side.

## Channel signature verification

Channels are the agent's ingress surface. Platform channels verify inbound webhooks before deriving caller identity; custom channels should follow the same contract.

### Built-in verifiers

| Channel | Mechanism | Replay protection |
| --- | --- | --- |
| Slack | HMAC-SHA256 over `v0:{timestamp}:{body}` (`X-Slack-Signature`) | 5-minute timestamp skew |
| GitHub | HMAC-SHA256 over raw body (`X-Hub-Signature-256`) | — |
| Twilio | HMAC-SHA1 over URL + sorted form params (`X-Twilio-Signature`) | — |
| Telegram | Constant-time compare of `X-Telegram-Bot-Api-Secret-Token` | — |

All HMAC comparisons use `timingSafeEqual` — never `===` on signature strings.

Each channel also accepts an optional `webhookVerifier` callback for forwarded traffic (for example Connect-authenticated webhooks verified with Vercel OIDC instead of the platform signing secret).

### Identity derivation rules

<Check>
Two rules apply to every channel — built-in and custom:
</Check>

1. **Verify signatures in constant time** over the raw request body (or delegate to a trusted verifier).
2. **Do not trust body-supplied identity.** Derive `principalId` from fields parsed only *after* verification succeeds — for example Slack's `team_id` + `user_id` from a signed payload, or Twilio's `From` from verified form params. A `principalId` claimed in an unauthenticated JSON body is attacker-controlled and enables cross-user impersonation.

Custom dashboard-style webhooks should authenticate the raw body with HMAC, compare in constant time, and only then map verified fields to `SessionAuthContext`.

## Route auth (fail-closed defaults)

Route auth is **inbound** HTTP protection on the Eve channel. It runs at the channel layer before any model work starts.

### Protected routes

`eveChannel({ auth })` guards these routes via `routeAuth`:

| Route | Auth required |
| --- | --- |
| `POST /eve/v1/session` | Yes |
| `POST /eve/v1/session/:sessionId` | Yes |
| `GET /eve/v1/session/:sessionId/stream` | Yes |
| `GET /eve/v1/health` | No — always public for load balancers |

Custom `defineChannel` routes should call `routeAuth(request, auth)` to reuse the same walk semantics, or emit `createUnauthorizedResponse(...)` for hand-rolled checks.

### Ordered auth walk

`auth` accepts a single `AuthFn` or an ordered array. Each entry has three outcomes:

| Return value | Effect |
| --- | --- |
| `SessionAuthContext` | Accept request; halt walk |
| `null` / `undefined` | Skip to next entry |
| Throw `UnauthenticatedError` / `ForbiddenError` | Reject with structured 401 / 403 |

If every entry skips — including `auth: []` — the request gets **401**. Admitting anonymous callers requires an explicit final `none()` entry.

```ts title="agent/channels/eve.ts"
import { eveChannel } from "eve/channels/eve";
import { localDev, vercelOidc } from "eve/channels/auth";

export default eveChannel({
  auth: [localDev(), vercelOidc()],
});
```

### Shipped verifiers

| Helper | Use when |
| --- | --- |
| `localDev()` | Loopback hostname or `vercel dev` (`VERCEL=1` + `VERCEL_ENV=development`) |
| `vercelOidc()` | Vercel deployment; verifies bearer JWT against Vercel OIDC issuer |
| `httpBasic()` | Shared username/password for operators |
| `jwtHmac()` / `jwtEcdsa()` | Custom JWT signers |
| `oidc()` | Arbitrary OIDC issuer |
| `none()` | Explicit anonymous access (must be final entry) |
| `placeholderAuth()` | Scaffold guardrail — structured 401 in production until replaced |

<Warning>
`localDev()` keys off the request URL hostname, not bare `process.env.VERCEL`. An origin that trusts attacker-controlled `Host` headers can be spoofed. Layer a real authenticator in production; never rely on `localDev()` alone.
</Warning>

`eve init` scaffolds `[localDev(), vercelOidc(), placeholderAuth()]`. In production, `placeholderAuth()` throws `UnauthenticatedError` with code `eve_production_auth_not_configured` so half-configured apps fail closed instead of serving open routes. Replace it with your app's `AuthFn` before browser traffic arrives. Deleting `agent/channels/eve.ts` falls back to `[localDev(), vercelOidc()]`, which still rejects production browser callers.

Route-auth secrets (`ROUTE_AUTH_BASIC_PASSWORD`, JWT signing keys) live in environment variables and are re-materialized at boot — never in compiled `.eve/` artifacts.

`createIpAllowList(...)` and `isIpAllowed(...)` can drop requests before auth and runtime execution based on client IP.

### Session auth propagation

After route auth succeeds, `buildRunContext` seeds `AuthKey` and `InitiatorAuthKey` into the runtime context:

| Field | Meaning |
| --- | --- |
| `ctx.session.auth.current` | Caller on the active inbound turn |
| `ctx.session.auth.initiator` | Caller that started the durable session |

Follow-up messages update `auth.current` but leave `auth.initiator` pinned. Both are `null` only on internal paths (subagents, schedules) that never traversed an authored route. Use these principals to scope tools, resolve dynamic capabilities, or enforce tenant boundaries — there is no second per-session ownership ACL beyond route auth.

## Authored markdown is data

Skills, schedules, and instructions authored as markdown carry YAML frontmatter parsed strictly as data. Eve disables gray-matter's built-in `javascript` / `js` frontmatter engines (which would `eval()` on parse). A `---js` or `---javascript` fence throws `"JavaScript frontmatter is not supported."` instead of executing. Frontmatter must parse to a plain YAML object.

## Pre-production checklist

<Steps>
<Step title="Replace placeholder auth">
Swap `placeholderAuth()` in `agent/channels/eve.ts` for a real `AuthFn` (`vercelOidc()`, `httpBasic()`, `oidc()`, or your own). Verify an unauthenticated production request returns `401`.
</Step>

<Step title="Verify channel signatures">
Set each platform channel's signing secret (`SLACK_SIGNING_SECRET`, GitHub webhook secret, `TWILIO_AUTH_TOKEN`, `TELEGRAM_WEBHOOK_SECRET_TOKEN`). Custom channels must verify HMAC in constant time and derive identity only after verification.
</Step>

<Step title="Keep secrets in process.env">
Never embed secrets in compiled artifacts or sandbox workspace files. Route privileged calls through tools, connections, or credential brokering.
</Step>

<Step title="Scope connection tokens">
Grant connections and tool `auth` strategies the least privilege required. Tokens reach external hosts on the trusted side, never the model.
</Step>

<Step title="Tighten sandbox egress">
Set a network policy tighter than `"allow-all"` when the model should not have open egress. Use credential brokering for authenticated egress to specific domains.
</Step>

<Step title="Escape untrusted UI text">
Model- or user-controlled strings rendered into channel UIs should be escaped for that surface.
</Step>
</Steps>

## Related pages

<CardGroup>
<Card title="Auth and deployment" href="/auth-and-deployment">
Route auth walk, env vars and secrets, `eve link` / `eve deploy`, and production verification.
</Card>
<Card title="Channels" href="/channels">
`defineChannel`, platform factories, webhook verification, and `eve channels` scaffolding.
</Card>
<Card title="Connections" href="/connections">
MCP and OpenAPI connections, OAuth callbacks, and `getToken` / `requireAuth` flows.
</Card>
<Card title="Sandbox" href="/sandbox">
`defineSandbox` backends, network policy, workspace seeding, and proxy execution.
</Card>
<Card title="Tools" href="/tools">
`defineTool` auth, HITL approval, and `requireAuth` on individual tools.
</Card>
</CardGroup>
