fix: fail closed for confidential run history

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-01 22:54:01 +00:00
1 parent d92ef1e4b4
commit c516a6c834
12 files changed
+335 -63

No files matched your search

+5 -2
View File
@@ -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
@@ -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);
});
@@ -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");
+24 -2
View File
@@ -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,
@@ -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"),
+1
View File
@@ -68,6 +68,7 @@ export interface HeartbeatRun {
id: string;
companyId: string;
agentId: string;
scopeKind: "company" | "issue";
issueId: string | null;
invocationSource: HeartbeatInvocationSource;
triggerDetail: WakeupTriggerDetail | null;
@@ -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";
+1 -1
View File
@@ -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;
}
+14 -4
View File
@@ -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<string, unknown> & { companyId: string; issueId?: string | null },
T extends Record<string, unknown> & 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<number | null>`(${heartbeatRuns.usageJson} ->> 'inputTokens')::numeric`.as("inputTokens"),
cachedInputTokens: sql<number | null>`(${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;
+14 -1
View File
@@ -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<boolean> {
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,
+10
View File
@@ -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,
+1
View File
@@ -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",