diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index dc04df6c..c18343af 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts @@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { prepareCommandManagedRuntime } from "./command-managed-runtime.js"; import { + authorizeSandboxCallbackBridgeRequestWithRoutes, createFileSystemSandboxCallbackBridgeQueueClient, createSandboxCallbackBridgeAsset, createSandboxCallbackBridgeToken, @@ -613,4 +614,95 @@ describe("sandbox callback bridge", () => { error: expect.stringMatching(/JSON|Unexpected|Unterminated/i), }); }); + + it("permits the documented heartbeat surface and denies unrelated routes", () => { + const allowed: Array<{ method: string; path: string }> = [ + { method: "GET", path: "/api/agents/me" }, + { method: "GET", path: "/api/agents/me/inbox-lite" }, + { method: "GET", path: "/api/agents/me/inbox/mine" }, + { method: "GET", path: "/api/agents/agent-1" }, + { method: "GET", path: "/api/agents/agent-1/skills" }, + { method: "POST", path: "/api/agents/agent-1/skills/sync" }, + { method: "PATCH", path: "/api/agents/agent-1/instructions-path" }, + { method: "GET", path: "/api/companies/co-1" }, + { method: "GET", path: "/api/companies/co-1/dashboard" }, + { method: "GET", path: "/api/companies/co-1/agents" }, + { method: "GET", path: "/api/companies/co-1/issues" }, + { method: "GET", path: "/api/companies/co-1/projects" }, + { method: "GET", path: "/api/companies/co-1/goals" }, + { method: "GET", path: "/api/companies/co-1/org" }, + { method: "GET", path: "/api/companies/co-1/approvals" }, + { method: "GET", path: "/api/companies/co-1/routines" }, + { method: "GET", path: "/api/companies/co-1/skills" }, + { method: "GET", path: "/api/projects/proj-1" }, + { method: "GET", path: "/api/goals/goal-1" }, + { method: "GET", path: "/api/issues/issue-1" }, + { method: "GET", path: "/api/issues/issue-1/heartbeat-context" }, + { method: "GET", path: "/api/issues/issue-1/comments" }, + { method: "GET", path: "/api/issues/issue-1/comments/c-1" }, + { method: "POST", path: "/api/issues/issue-1/comments" }, + { method: "GET", path: "/api/issues/issue-1/documents" }, + { method: "GET", path: "/api/issues/issue-1/documents/plan" }, + { method: "GET", path: "/api/issues/issue-1/documents/plan/revisions" }, + { method: "PUT", path: "/api/issues/issue-1/documents/plan" }, + { method: "POST", path: "/api/issues/issue-1/checkout" }, + { method: "POST", path: "/api/issues/issue-1/release" }, + { method: "PATCH", path: "/api/issues/issue-1" }, + { method: "GET", path: "/api/issues/issue-1/approvals" }, + { method: "GET", path: "/api/issues/issue-1/interactions" }, + { method: "GET", path: "/api/issues/issue-1/interactions/inter-1" }, + { method: "POST", path: "/api/issues/issue-1/interactions" }, + { method: "POST", path: "/api/issues/issue-1/interactions/inter-1/accept" }, + { method: "POST", path: "/api/issues/issue-1/interactions/inter-1/reject" }, + { method: "POST", path: "/api/issues/issue-1/interactions/inter-1/respond" }, + { method: "POST", path: "/api/companies/co-1/issues" }, + { method: "GET", path: "/api/approvals/ap-1" }, + { method: "GET", path: "/api/approvals/ap-1/issues" }, + { method: "GET", path: "/api/approvals/ap-1/comments" }, + { method: "POST", path: "/api/approvals/ap-1/comments" }, + { method: "POST", path: "/api/companies/co-1/approvals" }, + { method: "GET", path: "/api/execution-workspaces/ws-1" }, + { method: "POST", path: "/api/execution-workspaces/ws-1/runtime-services/start" }, + { method: "POST", path: "/api/execution-workspaces/ws-1/runtime-services/stop" }, + { method: "POST", path: "/api/execution-workspaces/ws-1/runtime-services/restart" }, + { method: "GET", path: "/api/routines/r-1" }, + { method: "GET", path: "/api/routines/r-1/runs" }, + { method: "POST", path: "/api/companies/co-1/routines" }, + { method: "PATCH", path: "/api/routines/r-1" }, + { method: "POST", path: "/api/routines/r-1/run" }, + { method: "POST", path: "/api/routines/r-1/triggers" }, + { method: "PATCH", path: "/api/routine-triggers/t-1" }, + { method: "DELETE", path: "/api/routine-triggers/t-1" }, + ]; + for (const request of allowed) { + expect(authorizeSandboxCallbackBridgeRequestWithRoutes(request)).toBeNull(); + } + + const denied: Array<{ method: string; path: string }> = [ + { method: "DELETE", path: "/api/secrets" }, + // Pin the runtime-services regex to start/stop/restart only — anything + // else (delete, reset, wipe, etc.) must stay denied even if the API + // grows new actions later. + { method: "POST", path: "/api/execution-workspaces/ws-1/runtime-services/delete" }, + { method: "POST", path: "/api/companies/co-1/agents" }, + { method: "POST", path: "/api/agents/agent-1/pause" }, + { method: "POST", path: "/api/agents/agent-1/terminate" }, + { method: "POST", path: "/api/agents/agent-1/keys" }, + { method: "POST", path: "/api/companies/co-1/exports" }, + { method: "POST", path: "/api/companies/co-1/imports/apply" }, + { method: "POST", path: "/api/companies/co-1/archive" }, + { method: "DELETE", path: "/api/issues/issue-1/documents/plan" }, + { method: "DELETE", path: "/api/issues/issue-1/approvals/ap-1" }, + { method: "POST", path: "/api/approvals/ap-1/approve" }, + { method: "POST", path: "/api/approvals/ap-1/reject" }, + { method: "POST", path: "/api/companies/co-1/logo" }, + { method: "GET", path: "/api/companies/co-1/secrets" }, + { method: "PATCH", path: "/api/secrets/secret-1" }, + ]; + for (const request of denied) { + expect(authorizeSandboxCallbackBridgeRequestWithRoutes(request)).toBe( + `Route not allowed: ${request.method} ${request.path}`, + ); + } + }); }); diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index 666ba9e9..cb020046 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -23,15 +23,76 @@ export interface SandboxCallbackBridgeRouteRule { path: RegExp; } +// Routes the in-sandbox heartbeat skill is documented to call. The server +// still enforces actor-level permissions on top of this allowlist; the list +// exists to bound the surface area a compromised CLI could reach via the +// reverse bridge. Keep this in sync with the Paperclip skill in +// `skills/paperclip/SKILL.md` and `references/api-reference.md`. export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCallbackBridgeRouteRule[] = [ + // Identity, inbox, agent self-management { method: "GET", path: /^\/api\/agents\/me$/ }, + { method: "GET", path: /^\/api\/agents\/me\/inbox-lite$/ }, + { method: "GET", path: /^\/api\/agents\/me\/inbox\/mine$/ }, + { method: "GET", path: /^\/api\/agents\/[^/]+$/ }, + { method: "GET", path: /^\/api\/agents\/[^/]+\/skills$/ }, + { method: "POST", path: /^\/api\/agents\/[^/]+\/skills\/sync$/ }, + { method: "PATCH", path: /^\/api\/agents\/[^/]+\/instructions-path$/ }, + + // Company-level reads used to discover work and context + { method: "GET", path: /^\/api\/companies\/[^/]+$/ }, + { method: "GET", path: /^\/api\/companies\/[^/]+\/dashboard$/ }, + { method: "GET", path: /^\/api\/companies\/[^/]+\/agents$/ }, + { method: "GET", path: /^\/api\/companies\/[^/]+\/issues$/ }, + { method: "GET", path: /^\/api\/companies\/[^/]+\/projects$/ }, + { method: "GET", path: /^\/api\/companies\/[^/]+\/goals$/ }, + { method: "GET", path: /^\/api\/companies\/[^/]+\/org$/ }, + { method: "GET", path: /^\/api\/companies\/[^/]+\/approvals$/ }, + { method: "GET", path: /^\/api\/companies\/[^/]+\/routines$/ }, + { method: "GET", path: /^\/api\/companies\/[^/]+\/skills$/ }, + { method: "GET", path: /^\/api\/projects\/[^/]+$/ }, + { method: "GET", path: /^\/api\/goals\/[^/]+$/ }, + + // Issue lifecycle: read context, checkout, update, comment, document, release + { method: "GET", path: /^\/api\/issues\/[^/]+$/ }, { method: "GET", path: /^\/api\/issues\/[^/]+\/heartbeat-context$/ }, { method: "GET", path: /^\/api\/issues\/[^/]+\/comments(?:\/[^/]+)?$/ }, - { method: "GET", path: /^\/api\/issues\/[^/]+\/documents(?:\/[^/]+)?$/ }, - { method: "POST", path: /^\/api\/issues\/[^/]+\/checkout$/ }, { method: "POST", path: /^\/api\/issues\/[^/]+\/comments$/ }, - { method: "POST", path: /^\/api\/issues\/[^/]+\/interactions(?:\/[^/]+)?$/ }, + { method: "GET", path: /^\/api\/issues\/[^/]+\/documents(?:\/[^/]+)?$/ }, + { method: "GET", path: /^\/api\/issues\/[^/]+\/documents\/[^/]+\/revisions$/ }, + { method: "PUT", path: /^\/api\/issues\/[^/]+\/documents\/[^/]+$/ }, + { method: "POST", path: /^\/api\/issues\/[^/]+\/checkout$/ }, + { method: "POST", path: /^\/api\/issues\/[^/]+\/release$/ }, { method: "PATCH", path: /^\/api\/issues\/[^/]+$/ }, + { method: "GET", path: /^\/api\/issues\/[^/]+\/approvals$/ }, + + // Issue-thread interactions (suggest tasks, ask questions, request confirmation) + { method: "GET", path: /^\/api\/issues\/[^/]+\/interactions(?:\/[^/]+)?$/ }, + { method: "POST", path: /^\/api\/issues\/[^/]+\/interactions$/ }, + { method: "POST", path: /^\/api\/issues\/[^/]+\/interactions\/[^/]+\/(?:accept|reject|respond)$/ }, + + // Subtasks / delegation + { method: "POST", path: /^\/api\/companies\/[^/]+\/issues$/ }, + + // Approvals (request, read, comment) + { method: "GET", path: /^\/api\/approvals\/[^/]+$/ }, + { method: "GET", path: /^\/api\/approvals\/[^/]+\/issues$/ }, + { method: "GET", path: /^\/api\/approvals\/[^/]+\/comments$/ }, + { method: "POST", path: /^\/api\/approvals\/[^/]+\/comments$/ }, + { method: "POST", path: /^\/api\/companies\/[^/]+\/approvals$/ }, + + // Execution workspaces and runtime services (start/stop/restart dev servers) + { method: "GET", path: /^\/api\/execution-workspaces\/[^/]+$/ }, + { method: "POST", path: /^\/api\/execution-workspaces\/[^/]+\/runtime-services\/(?:start|stop|restart)$/ }, + + // Routines (agents manage their own routines and triggers) + { method: "GET", path: /^\/api\/routines\/[^/]+$/ }, + { method: "GET", path: /^\/api\/routines\/[^/]+\/runs$/ }, + { method: "POST", path: /^\/api\/companies\/[^/]+\/routines$/ }, + { method: "PATCH", path: /^\/api\/routines\/[^/]+$/ }, + { method: "POST", path: /^\/api\/routines\/[^/]+\/run$/ }, + { method: "POST", path: /^\/api\/routines\/[^/]+\/triggers$/ }, + { method: "PATCH", path: /^\/api\/routine-triggers\/[^/]+$/ }, + { method: "DELETE", path: /^\/api\/routine-triggers\/[^/]+$/ }, ] as const; export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_HEADER_ALLOWLIST = [