Skip to content

fix(security): scope persistent-agent routes to the caller workspace - #574

Open
jonwiggins wants to merge 1 commit into
mainfrom
fix/persistent-agent-workspace-scoping
Open

fix(security): scope persistent-agent routes to the caller workspace#574
jonwiggins wants to merge 1 commit into
mainfrom
fix/persistent-agent-workspace-scoping

Conversation

@jonwiggins

Copy link
Copy Markdown
Owner

SECURITY

Found in an internal security review (no issue number). Cross-tenant takeover of Persistent Agents.

The vulnerability

Every id-addressed route in apps/api/src/routes/persistent-agents.ts resolved the agent by primary key with no workspace check (getPersistentAgent(id)). Sibling resources (tasks.ts, comments.ts) already scope by workspace; persistent-agents simply omitted it.

Because Persistent Agent ids are globally unique UUIDs surfaced in list/detail responses, any authenticated user in workspace B could take the id of an agent in workspace A and:

  • Read it (GET /:id), its inbox messages, its turns, and turn logs
  • Modify it (PATCH /:id) — including overwriting systemPrompt / initialPrompt
  • Delete it (DELETE /:id), or add/remove its triggers
  • Wake it (POST /:id/messages) and drive its control intent (POST /:id/control)

The most severe path: an attacker edits a victim agent's prompt and then wakes it, causing attacker-controlled instructions to execute inside the victim workspace's pod with the victim's connections/secrets. This is a full cross-tenant takeover, not just an info leak. (No working exploit payload is included here by design.)

The fix

Service (persistent-agent-service.ts)

  • Add getPersistentAgentScoped(id, workspaceId) — resolves an agent only within a workspace, via a shared wsPredicate (nullisNull(workspace_id), else eq), matching the existing getPersistentAgentBySlug / getPersistentAgentStats pattern.
  • Rename the existing unscoped getter to getPersistentAgentUnscoped so the legitimate non-user callers — workers, reconciler, internal inter-agent routes, webhook/schedule trigger dispatch, and the WS stream — keep an explicit unscoped path, and route code cannot reach the unscoped getter by habit.
  • Make updatePersistentAgent / deletePersistentAgent / setControlIntent workspace-scoped (new workspaceId param + and(eq(id), wsPredicate(workspaceId))).

Routes (persistent-agents.ts)

  • Add a shared requireAgent(req, reply, id) helper that fetches via the scoped getter and returns 404 (not 403 — no cross-tenant existence oracle) when the agent is missing or foreign. Every :id handler funnels through it (detail, patch, delete, messages POST/GET, turns, turn detail, triggers list/create, trigger delete, control).
  • Turn-detail additionally verifies turn.agentId === id; trigger-delete additionally scopes the delete itself to (targetType='persistent_agent', targetId=id) so a foreign trigger id can't be removed via a valid-for-caller :id.
  • Guard the mutating routes with preHandler: [requireRole("member")] (create, patch, delete, messages POST, triggers POST/DELETE, control), matching tasks.ts. Read routes are left unguarded.

Both requireRole and the workspace scoping short-circuit correctly when auth is disabled (isAuthDisabled / null workspace), so local dev is unaffected.

Tests

apps/api/src/routes/persistent-agents.test.ts had zero cross-workspace coverage. Added a suite asserting, for detail / PATCH / DELETE / messages / turns / turn-detail / trigger-delete / control:

  • a caller in a different workspace gets 404 and the mutating/wake path is never reached;
  • a same-workspace caller still succeeds, and the caller's workspace is forwarded to the scoped service calls.

Verification

  • cd apps/api && npx tsc --noEmit — clean
  • npx vitest run (full apps/api unit tier) — 123 files, 2184 tests pass
  • pnpm format:check and pnpm turbo typecheck — pass (pre-commit hooks green)

Follow-up (out of scope)

The WebSocket stream ws/persistent-agents/:agentId/events still resolves the agent unscoped (getPersistentAgentUnscoped), so it can leak another tenant's live events/logs. It is left unchanged here because authenticateWs returns only the user's default workspace and does no per-request workspace resolution (unlike the HTTP auth plugin's x-workspace-id handling), so scoping it correctly for multi-workspace users is a separate change. Recommend a dedicated follow-up.

Every id-addressed route in routes/persistent-agents.ts resolved the agent
by primary key with no workspace check, letting any authenticated tenant
read, modify, delete, or wake another tenant's persistent agent — a full
cross-tenant takeover (found in an internal security review).

- Add getPersistentAgentScoped(id, workspaceId); rename the unscoped getter
  to getPersistentAgentUnscoped so worker/reconciler/internal/webhook/WS
  callers keep an explicit unscoped path and routes can't reach it by habit.
- Scope updatePersistentAgent/deletePersistentAgent/setControlIntent by
  workspace via a shared wsPredicate.
- Funnel every :id handler through requireAgent(), which 404s (not 403 — no
  cross-tenant existence oracle) on a missing/foreign agent. Scope the turn
  detail and trigger DELETE by agent id too.
- Guard mutating routes with requireRole("member"), matching tasks.ts.
- Add cross-workspace route tests (404 for foreign caller, same-workspace
  still works) for detail/patch/delete/messages/turns/turn-detail/
  trigger-delete/control.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant