diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index acbd169ac22..b008d82d26c 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -340,8 +340,11 @@ No Docker or external database is required for this mode. Grant lookups use a five-second cache by default; override it with `PAPERCLIP_ISSUE_PRIVACY_CACHE_TTL_MS` when testing revocation timing. -Issue-bound heartbeat runs inherit the same read predicate through -`heartbeat_runs.issue_id`. In enforce mode, direct run detail, transcript, +Issue-bound heartbeat runs inherit the same read predicate through the stored +`heartbeat_runs.scope_kind = 'issue'` and `issue_id` binding. The database +rejects incomplete bindings and restricts deletion of referenced issues and +workspace-operation history, so confidential runs cannot become company-level +maintenance runs through foreign-key nulling. In enforce mode, direct run detail, transcript, event, log, and workspace-operation reads return `404` to non-members. Company run-history and live-run lists retain a metadata-only row with timing, status, token usage, and cost for budget oversight; issue identifiers, summaries, and diff --git a/packages/db/src/heartbeat-run-issue-privacy-migration.test.ts b/packages/db/src/heartbeat-run-issue-privacy-migration.test.ts index 6804c854272..7efe4bda9ab 100644 --- a/packages/db/src/heartbeat-run-issue-privacy-migration.test.ts +++ b/packages/db/src/heartbeat-run-issue-privacy-migration.test.ts @@ -65,8 +65,8 @@ describeEmbeddedPostgres("heartbeat run issue privacy migration", () => { await applyPendingMigrations(database.connectionString); - const rows = await sql<{ id: string; issue_id: string | null }[]>` - SELECT "id", "issue_id" + const rows = await sql<{ id: string; issue_id: string | null; scope_kind: string }[]>` + SELECT "id", "issue_id", "scope_kind" FROM "heartbeat_runs" WHERE "id" IN (${firstRunId}, ${secondRunId}, ${maintenanceRunId}) ORDER BY "id" @@ -75,6 +75,9 @@ describeEmbeddedPostgres("heartbeat run issue privacy migration", () => { expect(issueByRun.get(firstRunId)).toBe(firstIssueId); expect(issueByRun.get(secondRunId)).toBe(secondIssueId); expect(issueByRun.get(maintenanceRunId)).toBeNull(); + expect(rows.find((row) => row.id === firstRunId)?.scope_kind).toBe("issue"); + expect(rows.find((row) => row.id === secondRunId)?.scope_kind).toBe("issue"); + expect(rows.find((row) => row.id === maintenanceRunId)?.scope_kind).toBe("company"); const [parity] = await sql<{ expected_count: number; populated_count: number }[]>` SELECT @@ -93,4 +96,43 @@ describeEmbeddedPostgres("heartbeat run issue privacy migration", () => { `; expect(indexes).toHaveLength(1); }, 30_000); + + it("fails closed when a legacy issue claim cannot be resolved", async () => { + const database = await startEmbeddedPostgresTestDatabase("paperclip-run-issue-privacy-invalid-"); + cleanups.push(database.cleanup); + const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} }); + cleanups.push(async () => sql.end()); + + await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`; + + const companyId = randomUUID(); + const otherCompanyId = randomUUID(); + const agentId = randomUUID(); + const foreignIssueId = randomUUID(); + await sql` + INSERT INTO "companies" ("id", "name", "issue_prefix") + VALUES + (${companyId}, 'Run Privacy', 'RPI'), + (${otherCompanyId}, 'Other Company', 'RPO') + `; + await sql` + INSERT INTO "agents" ("id", "company_id", "name", "role", "adapter_type", "adapter_config") + VALUES (${agentId}, ${companyId}, 'Runner', 'engineer', 'process', '{}'::jsonb) + `; + await sql` + INSERT INTO "issues" ("id", "company_id", "title", "identifier") + VALUES (${foreignIssueId}, ${otherCompanyId}, 'Foreign task', 'RPO-1') + `; + await sql` + INSERT INTO "heartbeat_runs" ("company_id", "agent_id", "status", "context_snapshot") + VALUES + (${companyId}, ${agentId}, 'succeeded', ${sql.json({ issueId: "not-a-uuid" })}), + (${companyId}, ${agentId}, 'succeeded', ${sql.json({ issueId: randomUUID() })}), + (${companyId}, ${agentId}, 'succeeded', ${sql.json({ issueId: foreignIssueId })}) + `; + + await expect(applyPendingMigrations(database.connectionString)).rejects.toThrow( + /unresolved issue-scoped run/i, + ); + }, 30_000); }); diff --git a/packages/db/src/migrations/0199_heartbeat_run_issue_privacy.sql b/packages/db/src/migrations/0199_heartbeat_run_issue_privacy.sql index bdf64be378e..eef95517a6c 100644 --- a/packages/db/src/migrations/0199_heartbeat_run_issue_privacy.sql +++ b/packages/db/src/migrations/0199_heartbeat_run_issue_privacy.sql @@ -1,68 +1,143 @@ ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "issue_id" uuid; --> statement-breakpoint +ALTER TABLE "heartbeat_runs" ADD COLUMN IF NOT EXISTS "scope_kind" text NOT NULL DEFAULT 'company'; +--> statement-breakpoint +-- A retry can encounter the trigger from an earlier partial application. Keep +-- it out of the way while unresolved legacy claims are marked fail-closed. +DROP TRIGGER IF EXISTS "heartbeat_runs_set_scope_kind" ON "heartbeat_runs"; +--> statement-breakpoint +ALTER TABLE "heartbeat_runs" DROP CONSTRAINT IF EXISTS "heartbeat_runs_scope_binding_check"; +--> statement-breakpoint +-- Temporary support for the forward-only keyset backfill. Every row whose +-- snapshot claims an issue is classified as issue-scoped before constraints +-- are installed. Unresolvable claims deliberately fail the migration below. CREATE INDEX IF NOT EXISTS "heartbeat_runs_context_issue_backfill_idx" ON "heartbeat_runs" USING btree ("id") - WHERE "issue_id" IS NULL AND "context_snapshot" ? 'issueId'; + WHERE ("scope_kind" <> 'issue' OR "issue_id" IS NULL) + AND ("issue_id" IS NOT NULL OR "context_snapshot" ? 'issueId'); --> statement-breakpoint -UPDATE "heartbeat_runs" AS "run" -SET "issue_id" = "issue"."id" -FROM "issues" AS "issue" -WHERE "run"."issue_id" IS NULL - AND "run"."context_snapshot" ? 'issueId' - AND "issue"."id" = CASE - WHEN "run"."context_snapshot"->>'issueId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' - THEN ("run"."context_snapshot"->>'issueId')::uuid - ELSE NULL - END - AND "issue"."company_id" = "run"."company_id"; +ANALYZE "heartbeat_runs"; --> statement-breakpoint DO $$ DECLARE - "expected_count" bigint; - "populated_count" bigint; + last_run_id uuid := '00000000-0000-0000-0000-000000000000'::uuid; + next_last_run_id uuid; +BEGIN + LOOP + next_last_run_id := NULL; + + WITH batch AS MATERIALIZED ( + SELECT + "run"."id" AS run_id, + ( + SELECT "issue"."id" + FROM "issues" AS "issue" + WHERE "issue"."company_id" = "run"."company_id" + AND "issue"."id" = COALESCE( + "run"."issue_id", + CASE + WHEN "run"."context_snapshot"->>'issueId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' + THEN ("run"."context_snapshot"->>'issueId')::uuid + ELSE NULL + END + ) + LIMIT 1 + ) AS resolved_issue_id + FROM "heartbeat_runs" AS "run" + WHERE "run"."id" > last_run_id + AND ("run"."scope_kind" <> 'issue' OR "run"."issue_id" IS NULL) + AND ("run"."issue_id" IS NOT NULL OR "run"."context_snapshot" ? 'issueId') + ORDER BY "run"."id" + LIMIT 5000 + ), + updated AS ( + UPDATE "heartbeat_runs" AS "run" + SET "scope_kind" = 'issue', + "issue_id" = "batch"."resolved_issue_id" + FROM "batch" + WHERE "run"."id" = "batch"."run_id" + RETURNING "run"."id" + ) + SELECT "batch"."run_id" + INTO next_last_run_id + FROM "batch" + LEFT JOIN "updated" ON "updated"."id" = "batch"."run_id" + ORDER BY "batch"."run_id" DESC + LIMIT 1; + + EXIT WHEN next_last_run_id IS NULL; + last_run_id := next_last_run_id; + END LOOP; +END $$; +--> statement-breakpoint +DO $$ +DECLARE + unresolved_count bigint; BEGIN SELECT count(*) - INTO "expected_count" - FROM "heartbeat_runs" AS "run" - INNER JOIN "issues" AS "issue" - ON "issue"."company_id" = "run"."company_id" - AND "issue"."id" = CASE - WHEN "run"."context_snapshot"->>'issueId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' - THEN ("run"."context_snapshot"->>'issueId')::uuid - ELSE NULL - END - WHERE "run"."context_snapshot" ? 'issueId' - AND "run"."context_snapshot"->>'issueId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$'; + INTO unresolved_count + FROM "heartbeat_runs" + WHERE "scope_kind" = 'issue' AND "issue_id" IS NULL; - SELECT count(*) - INTO "populated_count" - FROM "heartbeat_runs" AS "run" - WHERE "run"."context_snapshot" ? 'issueId' - AND "run"."issue_id" = CASE - WHEN "run"."context_snapshot"->>'issueId' ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$' - THEN ("run"."context_snapshot"->>'issueId')::uuid - ELSE NULL - END; - - IF "expected_count" <> "populated_count" THEN - RAISE EXCEPTION 'heartbeat_runs.issue_id backfill parity failed: expected %, populated %', - "expected_count", "populated_count"; + IF unresolved_count > 0 THEN + RAISE EXCEPTION 'heartbeat run privacy backfill found % unresolved issue-scoped run(s); repair their issue binding before retrying migration 0199', unresolved_count; END IF; END $$; --> statement-breakpoint DROP INDEX IF EXISTS "heartbeat_runs_context_issue_backfill_idx"; --> statement-breakpoint -DO $$ BEGIN - IF NOT EXISTS ( - SELECT 1 FROM "pg_constraint" - WHERE "conname" = 'heartbeat_runs_issue_id_issues_id_fk' - ) THEN - ALTER TABLE "heartbeat_runs" - ADD CONSTRAINT "heartbeat_runs_issue_id_issues_id_fk" - FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") - ON DELETE SET NULL ON UPDATE NO ACTION; - END IF; +ALTER TABLE "heartbeat_runs" DROP CONSTRAINT IF EXISTS "heartbeat_runs_issue_id_issues_id_fk"; +--> statement-breakpoint +ALTER TABLE "heartbeat_runs" + ADD CONSTRAINT "heartbeat_runs_issue_id_issues_id_fk" + FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") + ON DELETE RESTRICT ON UPDATE NO ACTION NOT VALID; +--> statement-breakpoint +ALTER TABLE "heartbeat_runs" VALIDATE CONSTRAINT "heartbeat_runs_issue_id_issues_id_fk"; +--> statement-breakpoint +CREATE OR REPLACE FUNCTION "set_heartbeat_run_scope_kind"() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + NEW."scope_kind" := CASE WHEN NEW."issue_id" IS NULL THEN 'company' ELSE 'issue' END; + RETURN NEW; END $$; --> statement-breakpoint +DROP TRIGGER IF EXISTS "heartbeat_runs_set_scope_kind" ON "heartbeat_runs"; +--> statement-breakpoint +CREATE TRIGGER "heartbeat_runs_set_scope_kind" +BEFORE INSERT OR UPDATE OF "issue_id" ON "heartbeat_runs" +FOR EACH ROW EXECUTE FUNCTION "set_heartbeat_run_scope_kind"(); +--> statement-breakpoint +ALTER TABLE "heartbeat_runs" DROP CONSTRAINT IF EXISTS "heartbeat_runs_scope_binding_check"; +--> statement-breakpoint +ALTER TABLE "heartbeat_runs" + ADD CONSTRAINT "heartbeat_runs_scope_binding_check" + CHECK ( + ("scope_kind" = 'company' AND "issue_id" IS NULL) + OR ("scope_kind" = 'issue' AND "issue_id" IS NOT NULL) + ) NOT VALID; +--> statement-breakpoint +ALTER TABLE "heartbeat_runs" VALIDATE CONSTRAINT "heartbeat_runs_scope_binding_check"; +--> statement-breakpoint +ALTER TABLE "workspace_operations" DROP CONSTRAINT IF EXISTS "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk"; +--> statement-breakpoint +ALTER TABLE "workspace_operations" + ADD CONSTRAINT "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk" + FOREIGN KEY ("heartbeat_run_id") REFERENCES "public"."heartbeat_runs"("id") + ON DELETE RESTRICT ON UPDATE NO ACTION NOT VALID; +--> statement-breakpoint +ALTER TABLE "workspace_operations" VALIDATE CONSTRAINT "workspace_operations_heartbeat_run_id_heartbeat_runs_id_fk"; +--> statement-breakpoint +ALTER TABLE "workspace_operations" DROP CONSTRAINT IF EXISTS "workspace_operations_issue_id_issues_id_fk"; +--> statement-breakpoint +ALTER TABLE "workspace_operations" + ADD CONSTRAINT "workspace_operations_issue_id_issues_id_fk" + FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") + ON DELETE RESTRICT ON UPDATE NO ACTION NOT VALID; +--> statement-breakpoint +ALTER TABLE "workspace_operations" VALIDATE CONSTRAINT "workspace_operations_issue_id_issues_id_fk"; +--> statement-breakpoint CREATE INDEX IF NOT EXISTS "heartbeat_runs_company_issue_created_idx" ON "heartbeat_runs" USING btree ("company_id", "issue_id", "created_at"); diff --git a/packages/db/src/schema/heartbeat_runs.ts b/packages/db/src/schema/heartbeat_runs.ts index 3c4cf61a050..b8c4e2f5a34 100644 --- a/packages/db/src/schema/heartbeat_runs.ts +++ b/packages/db/src/schema/heartbeat_runs.ts @@ -1,4 +1,17 @@ -import { type AnyPgColumn, pgTable, uuid, text, timestamp, jsonb, index, integer, bigint, boolean } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { + bigint, + boolean, + check, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uuid, + type AnyPgColumn, +} from "drizzle-orm/pg-core"; import { companies } from "./companies.js"; import { agents } from "./agents.js"; import { agentWakeupRequests } from "./agent_wakeup_requests.js"; @@ -10,7 +23,11 @@ export const heartbeatRuns = pgTable( id: uuid("id").primaryKey().defaultRandom(), companyId: uuid("company_id").notNull().references(() => companies.id), agentId: uuid("agent_id").notNull().references(() => agents.id), - issueId: uuid("issue_id").references((): AnyPgColumn => issues.id, { onDelete: "set null" }), + scopeKind: text("scope_kind") + .$type<"company" | "issue">() + .notNull() + .default("company"), + issueId: uuid("issue_id").references((): AnyPgColumn => issues.id, { onDelete: "restrict" }), invocationSource: text("invocation_source").notNull().default("on_demand"), triggerDetail: text("trigger_detail"), status: text("status").notNull().default("queued"), @@ -61,6 +78,11 @@ export const heartbeatRuns = pgTable( updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, (table) => ({ + scopeBindingCheck: check( + "heartbeat_runs_scope_binding_check", + sql`(${table.scopeKind} = 'company' AND ${table.issueId} IS NULL) + OR (${table.scopeKind} = 'issue' AND ${table.issueId} IS NOT NULL)`, + ), companyAgentStartedIdx: index("heartbeat_runs_company_agent_started_idx").on( table.companyId, table.agentId, diff --git a/packages/db/src/schema/workspace_operations.ts b/packages/db/src/schema/workspace_operations.ts index 8924d5e7b33..3c730f74f5b 100644 --- a/packages/db/src/schema/workspace_operations.ts +++ b/packages/db/src/schema/workspace_operations.ts @@ -23,10 +23,10 @@ export const workspaceOperations = pgTable( onDelete: "set null", }), heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id, { - onDelete: "set null", + onDelete: "restrict", }), issueId: uuid("issue_id").references(() => issues.id, { - onDelete: "set null", + onDelete: "restrict", }), phase: text("phase").notNull(), command: text("command"), diff --git a/packages/shared/src/types/heartbeat.ts b/packages/shared/src/types/heartbeat.ts index e3d3b822dc3..f269a825670 100644 --- a/packages/shared/src/types/heartbeat.ts +++ b/packages/shared/src/types/heartbeat.ts @@ -68,6 +68,7 @@ export interface HeartbeatRun { id: string; companyId: string; agentId: string; + scopeKind: "company" | "issue"; issueId: string | null; invocationSource: HeartbeatInvocationSource; triggerDetail: WakeupTriggerDetail | null; diff --git a/server/src/__tests__/heartbeat-run-privacy-routes.test.ts b/server/src/__tests__/heartbeat-run-privacy-routes.test.ts index 6e3af5cf41e..906138fa907 100644 --- a/server/src/__tests__/heartbeat-run-privacy-routes.test.ts +++ b/server/src/__tests__/heartbeat-run-privacy-routes.test.ts @@ -1,10 +1,15 @@ import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; import express from "express"; import request from "supertest"; import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; import { agents, + agentRuntimeState, + activityLog, + authUsers, companies, + companyMemberships, createDb, heartbeatRunEvents, heartbeatRuns, @@ -35,13 +40,17 @@ describeEmbeddedPostgres.sequential("heartbeat run privacy routes", () => { }, 20_000); afterEach(async () => { + await db.delete(activityLog); await db.delete(workspaceOperations); await db.delete(heartbeatRunEvents); await db.delete(issueAccessGrants); + await db.delete(agentRuntimeState); await db.delete(heartbeatRuns); await db.delete(issues); await db.delete(agents); + await db.delete(companyMemberships); await db.delete(companies); + await db.delete(authUsers); }); afterAll(async () => { @@ -55,6 +64,8 @@ describeEmbeddedPostgres.sequential("heartbeat run privacy routes", () => { const ownerAgentId = randomUUID(); const grantedAgentId = randomUUID(); const otherAgentId = randomUUID(); + const ownerUserId = `owner-${randomUUID()}`; + const otherUserId = `other-${randomUUID()}`; const issueId = randomUUID(); const privateRunId = randomUUID(); const maintenanceRunId = randomUUID(); @@ -66,6 +77,21 @@ describeEmbeddedPostgres.sequential("heartbeat run privacy routes", () => { issuePrefix: "RPR", requireBoardApprovalForNewAgents: false, }); + await db.insert(authUsers).values([ownerUserId, otherUserId].map((id) => ({ + id, + name: id === ownerUserId ? "Owner user" : "Other user", + email: `${id}@example.test`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }))); + await db.insert(companyMemberships).values([ownerUserId, otherUserId].map((principalId) => ({ + companyId, + principalType: "user", + principalId, + status: "active", + membershipRole: "operator", + }))); await db.insert(agents).values([ { id: ownerAgentId, @@ -107,6 +133,7 @@ describeEmbeddedPostgres.sequential("heartbeat run privacy routes", () => { visibility: "private", privacyRootIssueId: issueId, status: "in_progress", + responsibleUserId: ownerUserId, assigneeAgentId: ownerAgentId, }); await db.insert(issueAccessGrants).values({ @@ -124,6 +151,7 @@ describeEmbeddedPostgres.sequential("heartbeat run privacy routes", () => { id: privateRunId, companyId, agentId: ownerAgentId, + scopeKind: "issue", issueId, invocationSource: "assignment", status: "running", @@ -139,6 +167,7 @@ describeEmbeddedPostgres.sequential("heartbeat run privacy routes", () => { id: maintenanceRunId, companyId, agentId: ownerAgentId, + scopeKind: "company", issueId: null, invocationSource: "timer", status: "succeeded", @@ -170,7 +199,18 @@ describeEmbeddedPostgres.sequential("heartbeat run privacy routes", () => { logRef: "missing-private-operation-log.ndjson", }); - return { companyId, ownerAgentId, grantedAgentId, otherAgentId, issueId, privateRunId, maintenanceRunId, operationId }; + return { + companyId, + ownerAgentId, + grantedAgentId, + otherAgentId, + ownerUserId, + otherUserId, + issueId, + privateRunId, + maintenanceRunId, + operationId, + }; } function createApp(companyId: string, agentId: string) { @@ -191,6 +231,24 @@ describeEmbeddedPostgres.sequential("heartbeat run privacy routes", () => { return app; } + function createBoardApp(companyId: string, userId: string) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { + type: "board", + userId, + companyIds: [companyId], + source: "session", + isInstanceAdmin: false, + }; + next(); + }); + app.use("/api", agentRoutes(db)); + app.use(errorHandler); + return app; + } + it("returns 404 for direct private-run content and metadata-only list stubs to a non-member", async () => { const fixture = await seedFixture(); const app = createApp(fixture.companyId, fixture.otherAgentId); @@ -266,6 +324,43 @@ describeEmbeddedPostgres.sequential("heartbeat run privacy routes", () => { expect(response.body).toEqual([]); }); + it("conceals private runs from unauthorized cancellation and watchdog mutations", async () => { + const fixture = await seedFixture(); + + const deniedCancel = await request(createBoardApp(fixture.companyId, fixture.otherUserId)) + .post(`/api/heartbeat-runs/${fixture.privateRunId}/cancel`); + expect(deniedCancel.status).toBe(404); + + const deniedWatchdog = await request(createApp(fixture.companyId, fixture.otherAgentId)) + .post(`/api/heartbeat-runs/${fixture.privateRunId}/watchdog-decisions`) + .send({ decision: "continue" }); + expect(deniedWatchdog.status).toBe(404); + + const allowedWatchdog = await request(createBoardApp(fixture.companyId, fixture.ownerUserId)) + .post(`/api/heartbeat-runs/${fixture.privateRunId}/watchdog-decisions`) + .send({ decision: "continue" }); + expect(allowedWatchdog.status).toBe(200); + + await db.update(heartbeatRuns) + .set({ status: "succeeded" }) + .where(eq(heartbeatRuns.id, fixture.privateRunId)); + const allowedCancel = await request(createBoardApp(fixture.companyId, fixture.ownerUserId)) + .post(`/api/heartbeat-runs/${fixture.privateRunId}/cancel`); + expect(allowedCancel.status).toBe(200); + expect(allowedCancel.body.id).toBe(fixture.privateRunId); + }); + + it("preserves issue and run bindings while confidential operation history exists", async () => { + const fixture = await seedFixture(); + + await expect(db.delete(issues).where(eq(issues.id, fixture.issueId))).rejects.toThrow(); + await expect(db.delete(heartbeatRuns).where(eq(heartbeatRuns.id, fixture.privateRunId))).rejects.toThrow(); + + const deniedDetail = await request(createApp(fixture.companyId, fixture.otherAgentId)) + .get(`/api/heartbeat-runs/${fixture.privateRunId}`); + expect(deniedDetail.status).toBe(404); + }); + it("logs would-deny decisions without enforcing them in shadow mode", async () => { const fixture = await seedFixture(); process.env.PAPERCLIP_ISSUE_PRIVACY_MODE = "shadow"; diff --git a/server/src/routes/activity.ts b/server/src/routes/activity.ts index 6d87a44832c..1a8ff605666 100644 --- a/server/src/routes/activity.ts +++ b/server/src/routes/activity.ts @@ -300,7 +300,7 @@ export function activityRoutes(db: Db) { } assertCompanyAccess(req, run.companyId); if (!(await assertCompanyScopeReadAllowed(req, res, run.companyId))) return; - if (!(await canActorReadHeartbeatRun(access, req.actor, run))) { + if (!(await canActorReadHeartbeatRun(db, access, req.actor, run))) { res.json([]); return; } diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index aa9be899692..f0a2e63dadc 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -121,6 +121,12 @@ import { const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000; const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024; +type RunReadBinding = { + companyId: string; + scopeKind?: "company" | "issue" | null; + issueId?: string | null; +}; + function readRunLogLimitBytes(value: unknown) { const parsed = Number(value ?? RUN_LOG_DEFAULT_LIMIT_BYTES); if (!Number.isFinite(parsed)) return RUN_LOG_DEFAULT_LIMIT_BYTES; @@ -205,14 +211,14 @@ export function agentRoutes( const instanceSettings = instanceSettingsService(db); const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; - async function actorCanReadRun(req: Request, run: { companyId: string; issueId?: string | null }) { - return canActorReadHeartbeatRun(access, req.actor, run); + async function actorCanReadRun(req: Request, run: RunReadBinding) { + return canActorReadHeartbeatRun(db, access, req.actor, run); } async function assertRunReadAllowed( req: Request, res: Response, - run: { companyId: string; issueId?: string | null }, + run: RunReadBinding, notFoundMessage = "Heartbeat run not found", ) { if (await actorCanReadRun(req, run)) return true; @@ -221,7 +227,7 @@ export function agentRoutes( } async function serializeRunListRow< - T extends Record & { companyId: string; issueId?: string | null }, + T extends Record & RunReadBinding, >(req: Request, run: T) { return await actorCanReadRun(req, run) ? run : redactHeartbeatRunListRow(run); } @@ -3714,6 +3720,7 @@ export function agentRoutes( lastOutputStream: heartbeatRuns.lastOutputStream, lastOutputBytes: heartbeatRuns.lastOutputBytes, processStartedAt: heartbeatRuns.processStartedAt, + scopeKind: heartbeatRuns.scopeKind, issueId: heartbeatRuns.issueId, inputTokens: sql`(${heartbeatRuns.usageJson} ->> 'inputTokens')::numeric`.as("inputTokens"), cachedInputTokens: sql`(${heartbeatRuns.usageJson} ->> 'cachedInputTokens')::numeric`.as("cachedInputTokens"), @@ -3797,6 +3804,7 @@ export function agentRoutes( const runId = req.params.runId as string; const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!existing) return; + if (!(await assertRunReadAllowed(req, res, existing))) return; const run = await heartbeat.cancelRun(runId); if (run) { @@ -3818,6 +3826,7 @@ export function agentRoutes( const runId = req.params.runId as string; const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!existing) return; + if (!(await assertRunReadAllowed(req, res, existing))) return; const decision = typeof req.body?.decision === "string" ? req.body.decision : ""; if (!["snooze", "continue", "dismissed_false_positive"].includes(decision)) { res.status(400).json({ error: "Unsupported watchdog decision" }); @@ -3903,6 +3912,7 @@ export function agentRoutes( : null; if (!(await assertRunReadAllowed(req, res, { companyId: operation.companyId, + scopeKind: operationRun?.scopeKind ?? (operation.issueId ? "issue" : null), issueId: operationRun?.issueId ?? operation.issueId ?? null, }, "Workspace operation not found"))) return; diff --git a/server/src/services/heartbeat-run-privacy.ts b/server/src/services/heartbeat-run-privacy.ts index 008eb7295e0..64152cc4157 100644 --- a/server/src/services/heartbeat-run-privacy.ts +++ b/server/src/services/heartbeat-run-privacy.ts @@ -1,11 +1,16 @@ +import type { Db } from "@paperclipai/db"; +import { issues } from "@paperclipai/db"; +import { and, eq } from "drizzle-orm"; import type { AuthorizationActor, AuthorizationDecision } from "./authorization.js"; type RunIssueBinding = { companyId: string; + scopeKind?: "company" | "issue" | null; issueId?: string | null; }; export async function canActorReadHeartbeatRun( + db: Db, access: { decide(input: { actor: AuthorizationActor; @@ -24,7 +29,15 @@ export async function canActorReadHeartbeatRun( actor: AuthorizationActor, run: RunIssueBinding, ): Promise { - if (!run.issueId) return true; + if (run.scopeKind === "company") return true; + if (!run.issueId) return false; + + const issueExists = await db + .select({ id: issues.id }) + .from(issues) + .where(and(eq(issues.id, run.issueId), eq(issues.companyId, run.companyId))) + .then((rows) => rows.length > 0); + if (!issueExists) return false; const decision = await access.decide({ actor, diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 2aa0b0239e2..50fe918fb9f 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1938,6 +1938,7 @@ const heartbeatRunListColumns = { id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId, + scopeKind: heartbeatRuns.scopeKind, issueId: heartbeatRuns.issueId, invocationSource: heartbeatRuns.invocationSource, triggerDetail: heartbeatRuns.triggerDetail, @@ -2092,6 +2093,7 @@ const heartbeatRunSqlAsciiSafeColumns = { const heartbeatRunLogAccessColumns = { id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, + scopeKind: heartbeatRuns.scopeKind, issueId: heartbeatRuns.issueId, logStore: heartbeatRuns.logStore, logRef: heartbeatRuns.logRef, @@ -9365,6 +9367,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .values({ companyId: run.companyId, agentId: run.agentId, + scopeKind: "issue", issueId, invocationSource: "automation", triggerDetail: "system", @@ -9616,6 +9619,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .values({ companyId: run.companyId, agentId: run.agentId, + scopeKind: issueId ? "issue" : "company", issueId, invocationSource: "automation", triggerDetail: "system", @@ -10897,6 +10901,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .values({ companyId: run.companyId, agentId: run.agentId, + scopeKind: issueId ? "issue" : "company", issueId, invocationSource: "automation", triggerDetail: "system", @@ -15837,6 +15842,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .values({ companyId: deferredAgent.companyId, agentId: deferredAgent.id, + scopeKind: "issue", issueId: issue.id, invocationSource: promotedSource, triggerDetail: promotedTriggerDetail, @@ -15992,6 +15998,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .values({ companyId: issue.companyId, agentId: recoveryAgent.id, + scopeKind: "issue", issueId: issue.id, invocationSource: "automation", triggerDetail: "system", @@ -16159,6 +16166,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .values({ companyId: issue.companyId, agentId: recoveryAgent.id, + scopeKind: "issue", issueId: issue.id, invocationSource: "automation", triggerDetail: "system", @@ -17277,6 +17285,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .values({ companyId: agent.companyId, agentId, + scopeKind: readNonEmptyString(enrichedContextSnapshot.issueId) ? "issue" : "company", issueId: readNonEmptyString(enrichedContextSnapshot.issueId), invocationSource: source, triggerDetail, @@ -17452,6 +17461,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) .values({ companyId: agent.companyId, agentId, + scopeKind: readNonEmptyString(enrichedContextSnapshot.issueId) ? "issue" : "company", issueId: readNonEmptyString(enrichedContextSnapshot.issueId), invocationSource: source, triggerDetail, diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index d5563ffd238..098e2852eec 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -3052,6 +3052,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) .values({ companyId: input.issue.companyId, agentId: input.agentId, + scopeKind: "issue", issueId: input.issue.id, invocationSource: "automation", triggerDetail: "system",