diff --git a/maps/agent-access-map/activity.md b/maps/agent-access-map/activity.md index 132833cc35e..f2f920dccb8 100644 --- a/maps/agent-access-map/activity.md +++ b/maps/agent-access-map/activity.md @@ -74,25 +74,49 @@ Review: `baseline`. API: `present` — representative operation IDs: - `GET /api/companies/{companyId}/heartbeat-runs`. +- `GET /api/companies/{companyId}/live-runs`. +- `GET /api/heartbeat-runs/{runId}`. - `GET /api/heartbeat-runs/{runId}/events`. +- `GET /api/heartbeat-runs/{runId}/log`. +- `GET /api/heartbeat-runs/{runId}/workspace-operations`. +- `GET /api/heartbeat-runs/{runId}/issues`. +- `GET /api/issues/{id}/runs`. +- `GET /api/issues/{id}/recovery-actions`. -Agent access: `available` — REST / native call_api / CLI run. +Agent access: `available` — REST / native call_api / CLI run. Sandbox agents can +use these diagnostic GET paths through the default queue and HTTP/2 +[callback bridge](../../packages/adapter-utils/src/sandbox-callback-bridge.ts). +Use the injected run credential; see the [bounded read workflow](../../skills/paperclip/references/run-diagnostics.md). -Policy: Same company plus visibility/audit/cost/run permissions where the route requires -them. +Policy: Same-company and `company_scope:read` checks apply to company run +inventory. Run content requires run/task visibility; private run list entries +may be redacted stubs. Task run/recovery reads require `issue:read`. Run-linked +tasks also pass the caller's task-read SQL predicate individually, including +activity links and the original task. Existing credential redaction remains. +Bridge admission grants no cancellation, retry, recovery resolution or +provider-trace administration authority. -Evidence: `source-only` — [implementation](../../server/src/routes/activity.ts#L224) at -the reviewed commit above. +Evidence: `source-only` — scoped review of `f10210990a3c9cad1a4359318388f237de3169dd`: +[run routes](../../server/src/routes/agents.ts), +[task links](../../server/src/routes/activity.ts), +[task filtering](../../server/src/services/activity.ts), +[bridge regressions](../../packages/adapter-utils/src/sandbox-callback-bridge.test.ts), +[HTTP/2 transport regressions](../../packages/adapter-utils/src/http2-bridge-server.test.ts), +and [private-run regressions](../../server/src/__tests__/heartbeat-run-privacy-routes.test.ts). -Verification: `not-run` — exercise a listed permitted operation with actual agent -credentials/run context; inspect its result and applicable persistence/attribution. -Assert the relevant denied actor, owner, company or mode from Policy. Review the -remaining operations before qualifying the whole capability. +Verification: `not-run` for a deployed sandbox agent with these new routes. +Isolated regressions cover admitted GETs, denied writes/extra segments, query +forwarding, controller denials, private run stubs, and mixed public/private task +links. They do not qualify a live run. After deployment, use a real agent run to +read an authorized failure and assert denial/redaction for another company, +private tasks and restricted key modes. Do not count a skipped database suite +as successful verification. Gaps: Telemetry/run visibility is checked independently from seeing the source task. Company heartbeat history defaults to 200 rows and caps explicit limits at 1,000 in both the route and service. The response remains an array. Scope, visibility, and secret redaction checks apply to the bounded result set. +Live updated-bridge admission and useful incident diagnosis remain unverified. ## `routine-audit` diff --git a/maps/agent-access-map/runs-adapters.md b/maps/agent-access-map/runs-adapters.md index b219918ba48..f49bba35587 100644 --- a/maps/agent-access-map/runs-adapters.md +++ b/maps/agent-access-map/runs-adapters.md @@ -139,22 +139,45 @@ API: `present` — representative operation IDs: - `GET /api/heartbeat-runs/{runId}`. - `GET /api/heartbeat-runs/{runId}/log`. +- `GET /api/heartbeat-runs/{runId}/events`. +- `GET /api/heartbeat-runs/{runId}/workspace-operations`. +- `GET /api/heartbeat-runs/{runId}/issues`. +- `GET /api/companies/{companyId}/heartbeat-runs`. +- `GET /api/companies/{companyId}/live-runs`. +- `GET /api/issues/{id}/runs`. +- `GET /api/issues/{id}/recovery-actions`. -Agent access: `available` — REST / native call_api / CLI run. +Agent access: `available` — REST / native call_api / CLI run, plus the listed +read-only paths through the default queue and HTTP/2 sandbox callback bridge. +The sandbox uses its injected run credential. See +[run diagnostics](../../skills/paperclip/references/run-diagnostics.md) and the +[activity access contract](activity.md#runs). -Policy: Run/cost visibility and agent configuration policy; adapter installation/account -login/session control require board/admin. +Policy: Same company, company-scope read authority and run/task visibility still +apply. Task-bound history and recovery records require task read authority. +Each run-linked task is filtered by the caller's task-read predicate; a readable +company run cannot expose inaccessible private task titles. Existing content +and credential redaction apply. Adapter installation, account login, session +control, recovery writes and raw provider-trace administration retain their +existing board/admin or dedicated-tool gates. -Evidence: `source-only` — [implementation](../../server/src/routes/agents.ts#L6608) at -the reviewed commit above. +Evidence: `source-only` — diagnostic scope reviewed at +`f10210990a3c9cad1a4359318388f237de3169dd` in the +[bridge route list](../../packages/adapter-utils/src/sandbox-callback-bridge.ts), +[run routes](../../server/src/routes/agents.ts), +[task-link route](../../server/src/routes/activity.ts), and +[private-run regression suite](../../server/src/__tests__/heartbeat-run-privacy-routes.test.ts). -Verification: `not-run` — exercise a listed permitted operation with actual agent -credentials/run context; inspect its result and applicable persistence/attribution. -Assert the relevant denied actor, owner, company or mode from Policy. Review the -remaining operations before qualifying the whole capability. +Verification: `not-run` for a live sandbox using the updated routes. Bridge and +controller fixtures cover permitted reads, denied writes, redaction and private +task-link filtering. After deployment, verify with an actual run identity and +assert foreign-company, private-task and restricted-mode denial. Source review, +board reads and skipped fixtures do not qualify that live path. Gaps: Provider trace reveals/downloads require instance-admin and cannot be counted as ordinary run output access. +The new bridge surface does not qualify retry, cancellation, workspace repair or +session-control access. Deployed diagnostic usefulness remains unverified. ## `session` diff --git a/packages/adapter-utils/src/http2-bridge-server.test.ts b/packages/adapter-utils/src/http2-bridge-server.test.ts index a3c284ddcfb..83c9ec547bf 100644 --- a/packages/adapter-utils/src/http2-bridge-server.test.ts +++ b/packages/adapter-utils/src/http2-bridge-server.test.ts @@ -1967,6 +1967,60 @@ describe("createHttp2BridgeServer + createSandboxHttp2BridgeGateway", () => { } }); + it("relays diagnostic reads and controller denials without admitting execution writes", async () => { + const seen: Http2BridgeForwardRequest[] = []; + const { gateway, handle } = createTestPair({ + routes: HTTP2_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST, + forwardRequest: async (request) => { + seen.push(request); + return { + status: request.pathname.includes("other-company") ? 403 : 200, + headers: { "content-type": "application/json" }, + body: Buffer.from(JSON.stringify({ path: request.pathname })), + }; + }, + }); + const query = "?limit=25&summary=true"; + try { + for (const requestPath of [ + "/api/companies/co-1/heartbeat-runs", + "/api/companies/co-1/live-runs", + "/api/heartbeat-runs/run-1", + "/api/heartbeat-runs/run-1/events", + "/api/heartbeat-runs/run-1/log", + "/api/heartbeat-runs/run-1/workspace-operations", + "/api/heartbeat-runs/run-1/issues", + "/api/issues/issue-1/runs", + "/api/issues/issue-1/recovery-actions", + "/api/companies/other-company/heartbeat-runs", + ]) { + const response = await gateway.forwardRequest({ + method: "GET", path: requestPath, query, headers: {}, + body: Buffer.alloc(0), receivedToken: BRIDGE_TOKEN, + }); + expect(response.status, requestPath).toBe(requestPath.includes("other-company") ? 403 : 200); + expect(JSON.parse(response.body.toString("utf8"))).toEqual({ path: requestPath }); + expect(seen.at(-1)).toMatchObject({ method: "GET", pathname: requestPath, query }); + expect(seen.at(-1)?.headers.authorization).toBeUndefined(); + } + const forwardedCount = seen.length; + for (const requestPath of [ + "/api/heartbeat-runs/run-1/cancel", + "/api/issues/issue-1/recovery-actions/resolve", + ]) { + const response = await gateway.forwardRequest({ + method: "POST", path: requestPath, query: "", headers: {}, + body: Buffer.from("{}"), receivedToken: BRIDGE_TOKEN, + }); + expect(response.status).toBe(403); + } + expect(seen).toHaveLength(forwardedCount); + } finally { + await gateway.close(); + await handle.close(); + } + }); + it("test_a_maximum_size_multipart_attachment_upload_fits_through_the_bridge", async () => { // A file at the exact attachment content ceiling (10 MiB — the same // figure `MAX_ATTACHMENT_BYTES` in `server/src/attachment-types.ts` diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts index 61e264709cf..b2c8c11edbe 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.test.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.test.ts @@ -1510,6 +1510,42 @@ describe("sandbox callback bridge", () => { await expect(readFile(remotePath, "utf8")).rejects.toThrow(); }); + it.each([ + "/api/companies/co-1/heartbeat-runs", + "/api/companies/co-1/live-runs", + "/api/heartbeat-runs/run-1", + "/api/heartbeat-runs/run-1/events", + "/api/heartbeat-runs/run-1/log", + "/api/heartbeat-runs/run-1/workspace-operations", + "/api/heartbeat-runs/run-1/issues", + "/api/issues/issue-1/runs", + "/api/issues/issue-1/recovery-actions", + ])("admits only GET for diagnostic path %s on both transports", (requestPath) => { + for (const routes of [undefined, HTTP2_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST]) { + expect(authorizeSandboxCallbackBridgeRequestWithRoutes({ method: "GET", path: requestPath }, routes)).toBeNull(); + for (const method of ["POST", "PUT", "PATCH", "DELETE"]) { + expect(authorizeSandboxCallbackBridgeRequestWithRoutes({ method, path: requestPath }, routes)) + .toBe(`Route not allowed: ${method} ${requestPath}`); + } + expect(authorizeSandboxCallbackBridgeRequestWithRoutes({ method: "GET", path: `${requestPath}/extra` }, routes)) + .toBe(`Route not allowed: GET ${requestPath}/extra`); + } + }); + + it.each([ + { method: "POST", path: "/api/heartbeat-runs/run-1/cancel" }, + { method: "POST", path: "/api/heartbeat-runs/run-1/retry" }, + { method: "POST", path: "/api/heartbeat-runs/run-1/watchdog-decisions" }, + { method: "POST", path: "/api/issues/issue-1/recovery-actions/resolve" }, + { method: "POST", path: "/api/issues/issue-1/recovery-actions/retry-workspace-export" }, + { method: "GET", path: "/api/heartbeat-runs/run-1/provider-trace" }, + { method: "GET", path: "/api/companies/co-1/provider-traces" }, + { method: "GET", path: "/api/companies/co-1/execution-workspaces" }, + ])("keeps execution control and unrelated reads denied: $method $path", (request) => { + expect(authorizeSandboxCallbackBridgeRequestWithRoutes(request)) + .toBe(`Route not allowed: ${request.method} ${request.path}`); + }); + it("permits the documented heartbeat surface and denies unrelated routes", () => { const allowed: Array<{ method: string; path: string }> = [ { method: "POST", path: "/api/companies/co-1/agent-commentary" }, diff --git a/packages/adapter-utils/src/sandbox-callback-bridge.ts b/packages/adapter-utils/src/sandbox-callback-bridge.ts index 7b41808b3d6..4cf624599e2 100644 --- a/packages/adapter-utils/src/sandbox-callback-bridge.ts +++ b/packages/adapter-utils/src/sandbox-callback-bridge.ts @@ -154,6 +154,12 @@ export const DEFAULT_SANDBOX_CALLBACK_BRIDGE_ROUTE_ALLOWLIST: readonly SandboxCa { method: "GET", path: /^\/api\/projects\/[^/]+$/ }, { method: "GET", path: /^\/api\/goals\/[^/]+$/ }, + { method: "GET", path: /^\/api\/companies\/[^/]+\/heartbeat-runs$/ }, + { method: "GET", path: /^\/api\/companies\/[^/]+\/live-runs$/ }, + { method: "GET", path: /^\/api\/heartbeat-runs\/[^/]+$/ }, + { method: "GET", path: /^\/api\/heartbeat-runs\/[^/]+\/(?:events|log|workspace-operations|issues)$/ }, + { method: "GET", path: /^\/api\/issues\/[^/]+\/(?:runs|recovery-actions)$/ }, + // Task-bound email actions. Company, inbox ownership, task/run authority, // and action policies are enforced by the controller; mailbox setup stays denied. { method: "GET", path: /^\/api\/companies\/[^/]+\/email\/inboxes$/ }, diff --git a/server/src/__tests__/activity-routes.test.ts b/server/src/__tests__/activity-routes.test.ts index d2090f9e5a1..080fe885331 100644 --- a/server/src/__tests__/activity-routes.test.ts +++ b/server/src/__tests__/activity-routes.test.ts @@ -334,6 +334,22 @@ describe("activity routes", () => { expect(mockActivityService.runsForIssue).not.toHaveBeenCalled(); }); + it("passes task read filtering when listing links for a readable company run", async () => { + mockHeartbeatService.getRun.mockResolvedValue({ + id: "run-1", companyId: "company-1", scopeKind: "company", issueId: null, + }); + mockActivityService.issuesForRun.mockResolvedValue([{ issueId: "public-issue", title: "Visible task" }]); + const app = await createApp({ type: "agent", companyId: "company-1", agentId: "agent-1" }); + const res = await requestApp(app, baseUrl => request(baseUrl).get("/api/heartbeat-runs/run-1/issues")); + expect(res.status).toBe(200); + const { issueReadSqlCondition } = await import("../services/authorization.js"); + expect(issueReadSqlCondition).toHaveBeenCalledWith({}, expect.objectContaining({ + type: "agent", companyId: "company-1", agentId: "agent-1", + })); + expect(mockActivityService.issuesForRun).toHaveBeenCalledWith("run-1", {}); + expect(res.body).toEqual([{ issueId: "public-issue", title: "Visible task" }]); + }); + it("returns 200 [] (not 404) when listing issues for another company's run, preserving API contract and the cross-tenant oracle", async () => { mockHeartbeatService.getRun.mockResolvedValue({ id: "run-2", diff --git a/server/src/__tests__/heartbeat-run-privacy-routes.test.ts b/server/src/__tests__/heartbeat-run-privacy-routes.test.ts index 6051e11d8c3..cb1fa213108 100644 --- a/server/src/__tests__/heartbeat-run-privacy-routes.test.ts +++ b/server/src/__tests__/heartbeat-run-privacy-routes.test.ts @@ -322,6 +322,36 @@ describeEmbeddedPostgres("heartbeat run privacy routes", { concurrent: false }, } }); + it("filters private task links from a readable company run while preserving authorized links", async () => { + const fixture = await seedFixture(); + const publicIssueId = randomUUID(); + await db.insert(issues).values({ + id: publicIssueId, companyId: fixture.companyId, identifier: "RPR-2", + title: "Public follow-up", visibility: "open", status: "todo", + }); + await db.insert(activityLog).values([fixture.issueId, publicIssueId].map(entityId => ({ + companyId: fixture.companyId, runId: fixture.maintenanceRunId, + agentId: fixture.ownerAgentId, actorType: "agent", actorId: fixture.ownerAgentId, + action: "issue.comment_added", entityType: "issue", entityId, + }))); + + const outsider = await request(createApp(fixture.companyId, fixture.otherAgentId)) + .get(`/api/heartbeat-runs/${fixture.maintenanceRunId}/issues`); + expect(outsider.status).toBe(200); + expect(outsider.body).toEqual([expect.objectContaining({ issueId: publicIssueId, title: "Public follow-up" })]); + expect(JSON.stringify(outsider.body)).not.toContain(fixture.issueId); + expect(JSON.stringify(outsider.body)).not.toContain("Confidential email triage"); + expect(JSON.stringify(outsider.body)).not.toContain("RPR-1"); + + for (const agentId of [fixture.ownerAgentId, fixture.grantedAgentId]) { + const response = await request(createApp(fixture.companyId, agentId)) + .get(`/api/heartbeat-runs/${fixture.maintenanceRunId}/issues`); + expect(response.status).toBe(200); + expect(response.body.map((row: { issueId: string }) => row.issueId).sort()) + .toEqual([fixture.issueId, publicIssueId].sort()); + } + }); + it("filters credential diagnostics in full-run and history responses by viewer", async () => { const fixture = await seedFixture(); const readerId = `reader-${randomUUID()}`; diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index f76311d0586..16832fa4122 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -377,7 +377,7 @@ export function activityRoutes(db: Db) { res.json([]); return; } - const result = await svc.issuesForRun(runId); + const result = await svc.issuesForRun(runId, await issueReadSqlCondition(db, req.actor)); res.json(result); }); diff --git a/server/src/services/activity.ts b/server/src/services/activity.ts index f115a4b8a24..ed208c5ee6d 100644 --- a/server/src/services/activity.ts +++ b/server/src/services/activity.ts @@ -587,7 +587,7 @@ export function activityService(db: Db) { }); }, - issuesForRun: async (runId: string) => { + issuesForRun: async (runId: string, readCondition: SQL) => { const run = await db .select({ companyId: heartbeatRuns.companyId, @@ -613,7 +613,9 @@ export function activityService(db: Db) { eq(activityLog.companyId, run.companyId), eq(activityLog.runId, runId), eq(activityLog.entityType, "issue"), + eq(issues.companyId, run.companyId), visibleIssueCondition(), + readCondition, ), ) .orderBy(issueIdAsText); @@ -636,6 +638,7 @@ export function activityService(db: Db) { eq(issues.companyId, run.companyId), eq(issues.id, contextIssueId), visibleIssueCondition(), + readCondition, ), ) .then((rows) => rows[0] ?? null); diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 72e12f6a189..46cdbd73f4b 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -1697,3 +1697,5 @@ per-call write approval, records activity, and dispatches the existing continuat An unauthorized approver receives 403; changed tool definitions, assignment, identity, or a closed task receive 409; unrelated connections receive 404. No credentials or provider authorization URL appear in the card payload. + +For sandbox run inspection, see [Run diagnostics from a sandbox](run-diagnostics.md). diff --git a/skills/paperclip/references/run-diagnostics.md b/skills/paperclip/references/run-diagnostics.md new file mode 100644 index 00000000000..f100ae1904a --- /dev/null +++ b/skills/paperclip/references/run-diagnostics.md @@ -0,0 +1,28 @@ +# Run diagnostics from a sandbox + +The queue and HTTP/2 sandbox bridges admit these read-only routes through the +normal injected Paperclip API credential. The server still checks company, +authorization-boundary and private-task access, and applies its existing run +content and credential redaction. Admission through the bridge does not grant +access to another company's runs or a private task. A list can return a redacted +metadata stub for an unreadable run; count that as unread coverage rather than +inferring a failure cause. + +| Method | Path | Description | +| ------ | ---- | ----------- | +| GET | `/api/companies/:companyId/heartbeat-runs` | Recent run history; use `?limit=200&summary=true` first (maximum 1000) | +| GET | `/api/companies/:companyId/live-runs` | Current active runs visible to this actor | +| GET | `/api/heartbeat-runs/:runId` | Run status, failure and execution details | +| GET | `/api/heartbeat-runs/:runId/events` | Run events; request bounded pages with `afterSeq` and `limit` | +| GET | `/api/heartbeat-runs/:runId/log` | Redacted log excerpt; use `offset` and `limitBytes` (maximum 1 MiB per call) | +| GET | `/api/heartbeat-runs/:runId/workspace-operations` | Workspace operation receipts for the run | +| GET | `/api/heartbeat-runs/:runId/issues` | Authorized task links for the run | +| GET | `/api/issues/:issueId/runs` | Run history for an authorized task | +| GET | `/api/issues/:issueId/recovery-actions` | Current and historical recovery records for an authorized task | + +Start with recent run summaries, correlate exact task/run/workspace IDs, then +read only the necessary events or log excerpts. Report result caps and denied +reads explicitly. These routes do not authorize retry, cancellation, replay, +workspace repair, recovery resolution, or provider resource changes. Execution +control writes and provider-trace administration remain outside this bridge +diagnostic surface. Do not borrow a board token to work around a denial.