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 /<prefix>/activity/commentary for
the agent's own company. Cross-company requests stay a plain 403.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 4279d57aa9)
This commit is contained in:
Dotta committed 2026-11-07 09:11:30 -06:00
1 parent 4c9bb36abb
commit 288257a052
2 files changed
+26 -3

No files matched your search

@@ -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);
+11 -2
View File
@@ -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");