diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 44f32b0e734..1bd34a526b5 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -1234,6 +1234,33 @@ describe("company skill mutation permissions", () => { expect(mockCompanySkillService.createLocalSkill).not.toHaveBeenCalled(); }); + it("does not convert consent gate service failures into authorization denials", async () => { + mockAccessService.decide.mockResolvedValue(denySkillChangeDecision( + "deny_missing_consent", + "Permission skills:suggest-changes requires accepted change consent before applying this mutation.", + )); + mockReflectionCoachMutationGate.assertConsented.mockRejectedValue(new Error("database unavailable")); + + const res = await request(await createApp({ + type: "agent", + agentId: "reflection-coach", + companyId: "company-1", + runId: "run-apply", + })) + .post("/api/companies/company-1/skills") + .send({ name: "Reflection Draft", slug: "reflection-draft", markdown: "# Draft" }); + + expect(res.status, JSON.stringify(res.body)).toBe(500); + expect(res.body.error).toBe("Internal server error"); + expect(mockReflectionCoachMutationGate.assertConsented).toHaveBeenCalledWith({ + companyId: "company-1", + actorAgentId: "reflection-coach", + actorRunId: "run-apply", + targetKeys: ["skill-slug:reflection-draft"], + }); + expect(mockCompanySkillService.createLocalSkill).not.toHaveBeenCalled(); + }); + it("allows suggest-tier skill mutations after accepted change consent", async () => { mockAccessService.decide .mockResolvedValueOnce(denySkillChangeDecision( diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index a25848387d9..b18a5aec381 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -31,9 +31,10 @@ import { listCatalogSkillsOrEmpty, readCatalogSkillFile, } from "../services/skills-catalog.js"; -import { forbidden } from "../errors.js"; +import { forbidden, HttpError } from "../errors.js"; import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js"; import { getTelemetryClient } from "../telemetry.js"; +import { authorizationDeniedDetails } from "../services/authorization.js"; import { changeConsentGateService, skillChangeTargetKey, @@ -139,8 +140,11 @@ export function companySkillRoutes(db: Db) { actorRunId: req.actor.runId ?? null, targetKeys, }); - } catch { - throw forbidden(decision.explanation, { reason: decision.reason }); + } catch (err) { + if (err instanceof HttpError && err.status === 403) { + throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); + } + throw err; } const consentedDecision = await access.decide({