From 4279d57aa9e3962efee7785970b960e28510cbbe Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 7 Nov 2026 09:11:13 -0600 Subject: [PATCH] fix(commentary): tell an agent denied a commentary read where a person can see it Agents can submit commentary but not read it. A denied agent got only "Board access required", so it could name the feed but not link it. The 403 now carries a remediation naming //activity/commentary for the agent's own company. Cross-company requests stay a plain 403. Co-Authored-By: Claude Opus 5.5 --- .../agent-commentary.integration.test.ts | 16 +++++++++++++++- server/src/routes/agent-commentary.ts | 13 +++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/agent-commentary.integration.test.ts b/server/src/__tests__/agent-commentary.integration.test.ts index bc0655633d6..16585c554af 100644 --- a/server/src/__tests__/agent-commentary.integration.test.ts +++ b/server/src/__tests__/agent-commentary.integration.test.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from "node:url"; import { and, eq, sql } from "drizzle-orm"; import express from "express"; import request from "supertest"; -import { activityLog, agentApiKeys, agentCommentary, agents, authUsers, companyMemberships, heartbeatRuns, issues, nativeRunResults } from "@paperclipai/db"; +import { activityLog, agentApiKeys, agentCommentary, agents, authUsers, companies, companyMemberships, heartbeatRuns, issues, nativeRunResults } from "@paperclipai/db"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createLocalAgentJwt } from "../agent-auth-jwt.js"; import { submitAgentCommentary } from "../services/agent-commentary.js"; @@ -125,6 +125,20 @@ describe("internal agent commentary through both transports", () => { expect(await rows(f)).toHaveLength(0); }); + it("tells an agent denied a commentary read where a person can see it, for its own company only", async () => { + const f = await legacy(); + const [company] = await server.db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, f.companyId)); + const read = (companyId: string) => fetch(`${server.apiUrl}/api/companies/${companyId}/agent-commentary`, { headers: { Authorization: `Bearer ${f.token}` } }); + const own = await read(f.companyId); + expect(own.status).toBe(403); + const body = await own.json() as { error: string; remediation?: string }; + expect(body.error).toBe("Board access required"); + expect(body.remediation).toContain(`/${company!.issuePrefix}/activity/commentary`); + const foreign = await read(f.foreignCompanyId); + expect(foreign.status).toBe(403); + expect(((await foreign.json()) as { remediation?: string }).remediation).toBeUndefined(); + }); + it.each(["cancelled", "succeeded", "failed"])("rejects submissions and replay after the run is %s", async (status) => { const f = await legacy(); expect((await post(f, { ...input, kind: "complaint" })).status).toBe(201); diff --git a/server/src/routes/agent-commentary.ts b/server/src/routes/agent-commentary.ts index 35aa021c61b..41d8910ee63 100644 --- a/server/src/routes/agent-commentary.ts +++ b/server/src/routes/agent-commentary.ts @@ -1,5 +1,6 @@ import { Router, type Request } from "express"; -import type { Db } from "@paperclipai/db"; +import { companies, type Db } from "@paperclipai/db"; +import { eq } from "drizzle-orm"; import { agentCommentaryQuerySchema, isUuidLike } from "@paperclipai/shared"; import { badRequest, forbidden, notFound } from "../errors.js"; import { submitAgentCommentary } from "../services/agent-commentary.js"; @@ -14,8 +15,16 @@ export function agentCommentaryRoutes(db: Db) { const access = accessService(db); async function authorizeRead(req: Request, companyId: string) { - assertBoard(req); assertCompanyAccess(req, companyId); + if (req.actor.type !== "board") { + // Agents can submit commentary but never read it. Name the human page so + // an agent can send the person somewhere useful instead of a dead end. + const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, companyId)).limit(1); + throw forbidden("Board access required", company ? { + remediation: `Agent commentary is readable only by people with commentary access. Send the person to /${company.issuePrefix}/activity/commentary.`, + } : undefined); + } + assertBoard(req); if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin && (!req.actor.userId || !await access.canUser(companyId, req.actor.userId, "commentary:view"))) { throw forbidden("Missing permission: commentary:view");