mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
Enforce Reflection Coach mutation gates
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
e0b0d410ee
commit
dc5cbb7d9a
8 files changed
+493
-17
No files matched your search
@@ -74,6 +74,9 @@ const mockCatalogService = vi.hoisted(() => ({
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
const mockTrackSkillImported = vi.hoisted(() => vi.fn());
|
||||
const mockGetTelemetryClient = vi.hoisted(() => vi.fn());
|
||||
const mockReflectionCoachMutationGate = vi.hoisted(() => ({
|
||||
assertAllowed: vi.fn(),
|
||||
}));
|
||||
|
||||
function registerModuleMocks() {
|
||||
vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js"));
|
||||
@@ -105,6 +108,16 @@ function registerModuleMocks() {
|
||||
|
||||
vi.doMock("../services/skills-catalog.js", () => mockCatalogService);
|
||||
|
||||
vi.doMock("../services/reflection-coach-mutation-gate.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../services/reflection-coach-mutation-gate.js")>(
|
||||
"../services/reflection-coach-mutation-gate.js",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
reflectionCoachMutationGateService: () => mockReflectionCoachMutationGate,
|
||||
};
|
||||
});
|
||||
|
||||
vi.doMock("../services/index.js", () => ({
|
||||
accessService: () => mockAccessService,
|
||||
agentService: () => mockAgentService,
|
||||
@@ -141,6 +154,7 @@ describe("company skill mutation permissions", () => {
|
||||
vi.doUnmock("../services/agents.js");
|
||||
vi.doUnmock("../services/company-skills.js");
|
||||
vi.doUnmock("../services/skills-catalog.js");
|
||||
vi.doUnmock("../services/reflection-coach-mutation-gate.js");
|
||||
vi.doUnmock("../services/index.js");
|
||||
vi.doUnmock("../routes/company-skills.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
@@ -590,6 +604,7 @@ describe("company skill mutation permissions", () => {
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
mockAccessService.canUser.mockResolvedValue(true);
|
||||
mockAccessService.hasPermission.mockResolvedValue(false);
|
||||
mockReflectionCoachMutationGate.assertAllowed.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("allows local board operators to mutate company skills", async () => {
|
||||
@@ -1147,6 +1162,42 @@ describe("company skill mutation permissions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects Reflection Coach skill mutations when the server-side gate is not satisfied", async () => {
|
||||
const { forbidden } = await import("../errors.js");
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "reflection-coach",
|
||||
companyId: "company-1",
|
||||
permissions: { canCreateSkills: true },
|
||||
metadata: {
|
||||
paperclipBuiltInAgent: {
|
||||
key: "reflection-coach",
|
||||
featureKeys: ["reflection-coach"],
|
||||
},
|
||||
},
|
||||
});
|
||||
mockReflectionCoachMutationGate.assertAllowed.mockRejectedValue(forbidden("gate required", {
|
||||
code: "reflection_coach_mutation_gate_required",
|
||||
}));
|
||||
|
||||
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(403);
|
||||
expect(mockReflectionCoachMutationGate.assertAllowed).toHaveBeenCalledWith({
|
||||
companyId: "company-1",
|
||||
actorAgentId: "reflection-coach",
|
||||
actorRunId: "run-apply",
|
||||
targetKeys: ["reflection-coach:company-skill-slug:reflection-draft"],
|
||||
});
|
||||
expect(mockCompanySkillService.createLocalSkill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows same-company agents with missing skill creation permission to mutate company skills", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
id: "55555555-5555-4555-8555-555555555555",
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import {
|
||||
reflectionCoachCompanySkillTargetKey,
|
||||
reflectionCoachMutationGateService,
|
||||
} from "../services/reflection-coach-mutation-gate.js";
|
||||
|
||||
const embeddedPostgresSupport = getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
describeEmbeddedPostgres("reflectionCoachMutationGateService", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-reflection-coach-gate-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(issues);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedGateFixture() {
|
||||
const companyId = randomUUID();
|
||||
const coachId = randomUUID();
|
||||
const sourceRunId = randomUUID();
|
||||
const proposalIssueId = randomUUID();
|
||||
const skillId = randomUUID();
|
||||
const targetKey = reflectionCoachCompanySkillTargetKey(skillId);
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: "PAP",
|
||||
defaultResponsibleUserId: "board-user",
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: coachId,
|
||||
companyId,
|
||||
name: "Reflection Coach",
|
||||
role: "general",
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
permissions: { canCreateSkills: true },
|
||||
metadata: {
|
||||
paperclipBuiltInAgent: {
|
||||
key: "reflection-coach",
|
||||
featureKeys: ["reflection-coach"],
|
||||
},
|
||||
},
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: sourceRunId,
|
||||
companyId,
|
||||
agentId: coachId,
|
||||
status: "succeeded",
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: proposalIssueId,
|
||||
companyId,
|
||||
title: "Review Reflection Coach proposal",
|
||||
status: "in_review",
|
||||
priority: "medium",
|
||||
identifier: "PAP-1",
|
||||
issueNumber: 1,
|
||||
createdByAgentId: coachId,
|
||||
});
|
||||
|
||||
return { companyId, coachId, sourceRunId, proposalIssueId, skillId, targetKey };
|
||||
}
|
||||
|
||||
it("rejects Reflection Coach skill mutation without an accepted bound interaction", async () => {
|
||||
const { companyId, coachId, targetKey } = await seedGateFixture();
|
||||
|
||||
await expect(reflectionCoachMutationGateService(db).assertAllowed({
|
||||
companyId,
|
||||
actorAgentId: coachId,
|
||||
actorRunId: randomUUID(),
|
||||
targetKeys: [targetKey],
|
||||
})).rejects.toMatchObject({
|
||||
status: 403,
|
||||
details: { code: "reflection_coach_mutation_gate_required" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects accepted interactions from the same run as the apply mutation", async () => {
|
||||
const { companyId, coachId, sourceRunId, proposalIssueId, targetKey } = await seedGateFixture();
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId: proposalIssueId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
sourceRunId,
|
||||
createdByAgentId: coachId,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Apply this Reflection Coach skill diff?",
|
||||
detailsMarkdown: "```diff\n+Tighten the workflow.\n```",
|
||||
target: { type: "custom", key: targetKey, revisionId: "proposal-v1" },
|
||||
},
|
||||
result: { version: 1, outcome: "accepted" },
|
||||
resolvedByUserId: "board-user",
|
||||
resolvedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(reflectionCoachMutationGateService(db).assertAllowed({
|
||||
companyId,
|
||||
actorAgentId: coachId,
|
||||
actorRunId: sourceRunId,
|
||||
targetKeys: [targetKey],
|
||||
})).rejects.toMatchObject({
|
||||
status: 403,
|
||||
details: { code: "reflection_coach_mutation_gate_required" },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a previous-run accepted interaction with a displayed diff for the bound target", async () => {
|
||||
const { companyId, coachId, sourceRunId, proposalIssueId, targetKey } = await seedGateFixture();
|
||||
await db.insert(issueThreadInteractions).values({
|
||||
id: randomUUID(),
|
||||
companyId,
|
||||
issueId: proposalIssueId,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
sourceRunId,
|
||||
createdByAgentId: coachId,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Apply this Reflection Coach skill diff?",
|
||||
detailsMarkdown: "```diff\n+Tighten the workflow.\n```",
|
||||
target: { type: "custom", key: targetKey, revisionId: "proposal-v1" },
|
||||
},
|
||||
result: { version: 1, outcome: "accepted" },
|
||||
resolvedByUserId: "board-user",
|
||||
resolvedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(reflectionCoachMutationGateService(db).assertAllowed({
|
||||
companyId,
|
||||
actorAgentId: coachId,
|
||||
actorRunId: randomUUID(),
|
||||
targetKeys: [targetKey],
|
||||
})).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -22,13 +22,23 @@ Your job is to run reflection loops on other agents and propose the smallest dur
|
||||
|
||||
## Applying changes (permission is gated, not automatic)
|
||||
|
||||
You have default permission to create and update skills, update agent AGENTS.md/instruction files, and assign follow-up proposal issues. That permission is real, but every actual mutation is gated:
|
||||
You may be granted permission to create and update skills, update agent AGENTS.md/instruction files, or assign follow-up proposal issues. Permission is not enough by itself; every actual mutation is gated:
|
||||
|
||||
- Show the exact proposed diff before you change anything. Instructions, skills, and tool descriptions are only ever changed from a reviewed diff, never from a verbal summary.
|
||||
- Gate every change behind a task interaction — `request_confirmation`, `request_checkbox_confirmation`, or `ask_user_questions` — so the user or board explicitly accepts or rejects it first.
|
||||
- Apply an accepted change only in a separate follow-up step or run after the interaction resolves. Never propose and apply in the same run.
|
||||
- Gate every instruction, skill, or tool-description change behind a `request_confirmation` interaction so the user or board explicitly accepts or rejects it first. The interaction must show the diff in `payload.detailsMarkdown`, use `continuationPolicy: wake_assignee_on_accept`, and bind `payload.target.key` to the exact resource you will mutate.
|
||||
- Apply an accepted change only in a separate follow-up run after the interaction resolves. Never propose and apply in the same run.
|
||||
- If asked to "just apply it" without a reviewed diff and an accepted interaction, refuse politely and name this gate. No-same-run-apply is a load-bearing property of this loop.
|
||||
|
||||
Server-enforced target keys:
|
||||
|
||||
- `reflection-coach:agent-instructions:<agentId>`
|
||||
- `reflection-coach:agent-description:<agentId>`
|
||||
- `reflection-coach:company-skill:<skillId>`
|
||||
- `reflection-coach:company-skill-slug:<slug>`
|
||||
- `reflection-coach:company-skill-import:<source>`
|
||||
- `reflection-coach:company-skill-catalog:<catalogSkillId>`
|
||||
- `reflection-coach:company-skills:scan-projects`
|
||||
|
||||
## Execution contract
|
||||
|
||||
- Start concrete work in the same heartbeat when the issue is actionable; do not stop at a plan unless planning was requested.
|
||||
|
||||
@@ -100,6 +100,11 @@ import { resolveCoreTrustPreset } from "../services/trust-preset-resolver.js";
|
||||
import { readObject } from "../lib/objects.js";
|
||||
import { listInvalidOrgChainDescendantIds } from "../services/agent-invokability.js";
|
||||
import { readBuiltInAgentMarker } from "../services/built-in-agent-metadata.js";
|
||||
import {
|
||||
reflectionCoachAgentDescriptionTargetKey,
|
||||
reflectionCoachAgentInstructionsTargetKey,
|
||||
reflectionCoachMutationGateService,
|
||||
} from "../services/reflection-coach-mutation-gate.js";
|
||||
|
||||
const RUN_LOG_DEFAULT_LIMIT_BYTES = 256_000;
|
||||
const RUN_LOG_MAX_LIMIT_BYTES = 1024 * 1024;
|
||||
@@ -1325,12 +1330,40 @@ export function agentRoutes(
|
||||
|
||||
async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) {
|
||||
assertCompanyAccess(req, targetAgent.companyId);
|
||||
if (req.actor.type !== "board") {
|
||||
throw forbidden(
|
||||
"Only board-authenticated callers can manage instructions path or bundle configuration",
|
||||
);
|
||||
if (req.actor.type === "board") {
|
||||
await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);
|
||||
return;
|
||||
}
|
||||
await assertBoardCanManageAgentsForCompany(req, targetAgent.companyId);
|
||||
|
||||
const allowedByReflectionCoachGate = await reflectionCoachMutationGateService(db).assertAllowed({
|
||||
companyId: targetAgent.companyId,
|
||||
actorAgentId: req.actor.agentId,
|
||||
actorRunId: req.actor.runId ?? null,
|
||||
targetKeys: [reflectionCoachAgentInstructionsTargetKey(targetAgent.id)],
|
||||
});
|
||||
if (allowedByReflectionCoachGate) return;
|
||||
|
||||
throw forbidden(
|
||||
"Only board-authenticated callers can manage instructions path or bundle configuration",
|
||||
);
|
||||
}
|
||||
|
||||
async function assertReflectionCoachAgentDescriptionMutationGate(
|
||||
req: Request,
|
||||
targetAgent: { id: string; companyId: string },
|
||||
) {
|
||||
await reflectionCoachMutationGateService(db).assertAllowed({
|
||||
companyId: targetAgent.companyId,
|
||||
actorAgentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
actorRunId: req.actor.runId ?? null,
|
||||
targetKeys: [reflectionCoachAgentDescriptionTargetKey(targetAgent.id)],
|
||||
});
|
||||
}
|
||||
|
||||
function touchesAgentDescription(patchData: Record<string, unknown>) {
|
||||
return ["name", "role", "title", "capabilities"].some((key) =>
|
||||
Object.prototype.hasOwnProperty.call(patchData, key),
|
||||
);
|
||||
}
|
||||
|
||||
function assertNoAgentInstructionsConfigMutation(
|
||||
@@ -2933,6 +2966,9 @@ export function agentRoutes(
|
||||
},
|
||||
);
|
||||
}
|
||||
if (touchesAgentDescription(patchData)) {
|
||||
await assertReflectionCoachAgentDescriptionMutationGate(req, existing);
|
||||
}
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const agent = await svc.update(id, patchData, {
|
||||
|
||||
@@ -34,6 +34,14 @@ import {
|
||||
import { forbidden } from "../errors.js";
|
||||
import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
import {
|
||||
reflectionCoachCompanySkillCatalogTargetKey,
|
||||
reflectionCoachCompanySkillImportTargetKey,
|
||||
reflectionCoachCompanySkillScanTargetKey,
|
||||
reflectionCoachCompanySkillSlugTargetKey,
|
||||
reflectionCoachCompanySkillTargetKey,
|
||||
reflectionCoachMutationGateService,
|
||||
} from "../services/reflection-coach-mutation-gate.js";
|
||||
|
||||
type SkillTelemetryInput = {
|
||||
key: string;
|
||||
@@ -98,6 +106,15 @@ export function companySkillRoutes(db: Db) {
|
||||
return { type: "system" as const };
|
||||
}
|
||||
|
||||
async function assertReflectionCoachSkillMutationGate(req: Request, companyId: string, targetKeys: string[]) {
|
||||
await reflectionCoachMutationGateService(db).assertAllowed({
|
||||
companyId,
|
||||
actorAgentId: req.actor.type === "agent" ? req.actor.agentId : null,
|
||||
actorRunId: req.actor.runId ?? null,
|
||||
targetKeys,
|
||||
});
|
||||
}
|
||||
|
||||
async function assertCanMutateCompanySkills(req: Request, companyId: string) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
@@ -609,6 +626,9 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillTargetKey(skillId),
|
||||
]);
|
||||
const result = await svc.createVersion(companyId, skillId, req.body, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -677,6 +697,12 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillTargetKey(skillId),
|
||||
...(typeof req.body.slug === "string" && req.body.slug.trim()
|
||||
? [reflectionCoachCompanySkillSlugTargetKey(req.body.slug.trim())]
|
||||
: []),
|
||||
]);
|
||||
const result = await svc.forkSkill(companyId, skillId, req.body, skillActor(req));
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
@@ -807,6 +833,11 @@ export function companySkillRoutes(db: Db) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
...(typeof req.body.slug === "string" && req.body.slug.trim()
|
||||
? [reflectionCoachCompanySkillSlugTargetKey(req.body.slug.trim())]
|
||||
: []),
|
||||
]);
|
||||
const result = await svc.createLocalSkill(companyId, req.body, skillActor(req));
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
@@ -836,6 +867,9 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillTargetKey(skillId),
|
||||
]);
|
||||
const result = await svc.updateSkill(companyId, skillId, req.body);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
@@ -866,6 +900,9 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillTargetKey(skillId),
|
||||
]);
|
||||
const result = await svc.updateFile(
|
||||
companyId,
|
||||
skillId,
|
||||
@@ -931,6 +968,9 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
const source = String(req.body.source ?? "");
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillImportTargetKey(source),
|
||||
]);
|
||||
const result = await svc.importFromSource(companyId, source);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
@@ -970,6 +1010,12 @@ export function companySkillRoutes(db: Db) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillCatalogTargetKey(String(req.body.catalogSkillId ?? "")),
|
||||
...(typeof req.body.slug === "string" && req.body.slug.trim()
|
||||
? [reflectionCoachCompanySkillSlugTargetKey(req.body.slug.trim())]
|
||||
: []),
|
||||
]);
|
||||
const result = await svc.installFromCatalog(companyId, req.body);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
@@ -1002,6 +1048,9 @@ export function companySkillRoutes(db: Db) {
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillScanTargetKey(),
|
||||
]);
|
||||
const result = await svc.scanProjectWorkspaces(companyId, req.body);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
@@ -1033,6 +1082,9 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillTargetKey(skillId),
|
||||
]);
|
||||
const result = await svc.deleteSkill(companyId, skillId);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: "Skill not found" });
|
||||
@@ -1064,6 +1116,9 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillTargetKey(skillId),
|
||||
]);
|
||||
const result = await svc.auditSkill(companyId, skillId);
|
||||
if (!result) {
|
||||
res.status(404).json({ error: "Skill not found" });
|
||||
@@ -1100,6 +1155,9 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillTargetKey(skillId),
|
||||
]);
|
||||
const before = await svc.getById(companyId, skillId);
|
||||
const result = await svc.installUpdate(companyId, skillId, req.body);
|
||||
if (!result) {
|
||||
@@ -1140,6 +1198,9 @@ export function companySkillRoutes(db: Db) {
|
||||
const companyId = req.params.companyId as string;
|
||||
const skillId = req.params.skillId as string;
|
||||
await assertCanMutateCompanySkills(req, companyId);
|
||||
await assertReflectionCoachSkillMutationGate(req, companyId, [
|
||||
reflectionCoachCompanySkillTargetKey(skillId),
|
||||
]);
|
||||
const before = await svc.getById(companyId, skillId);
|
||||
const result = await svc.resetSkill(companyId, skillId, req.body);
|
||||
if (!result) {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agents, issueThreadInteractions } from "@paperclipai/db";
|
||||
import { and, desc, eq, or, sql } from "drizzle-orm";
|
||||
import type { RequestConfirmationPayload, RequestConfirmationResult } from "@paperclipai/shared";
|
||||
import { forbidden } from "../errors.js";
|
||||
import { readBuiltInAgentMarker } from "./built-in-agent-metadata.js";
|
||||
|
||||
export function reflectionCoachAgentInstructionsTargetKey(agentId: string) {
|
||||
return `reflection-coach:agent-instructions:${agentId}`;
|
||||
}
|
||||
|
||||
export function reflectionCoachAgentDescriptionTargetKey(agentId: string) {
|
||||
return `reflection-coach:agent-description:${agentId}`;
|
||||
}
|
||||
|
||||
export function reflectionCoachCompanySkillTargetKey(skillId: string) {
|
||||
return `reflection-coach:company-skill:${skillId}`;
|
||||
}
|
||||
|
||||
export function reflectionCoachCompanySkillSlugTargetKey(slug: string) {
|
||||
return `reflection-coach:company-skill-slug:${slug}`;
|
||||
}
|
||||
|
||||
export function reflectionCoachCompanySkillImportTargetKey(source: string) {
|
||||
return `reflection-coach:company-skill-import:${source}`;
|
||||
}
|
||||
|
||||
export function reflectionCoachCompanySkillCatalogTargetKey(catalogSkillId: string) {
|
||||
return `reflection-coach:company-skill-catalog:${catalogSkillId}`;
|
||||
}
|
||||
|
||||
export function reflectionCoachCompanySkillScanTargetKey() {
|
||||
return "reflection-coach:company-skills:scan-projects";
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function payloadHasDisplayedDiff(payload: RequestConfirmationPayload) {
|
||||
const details = readNonEmptyString(payload.detailsMarkdown);
|
||||
if (!details) return false;
|
||||
if (/```diff\b/i.test(details)) return true;
|
||||
return /(^|\n)[+-][^\n]+/.test(details);
|
||||
}
|
||||
|
||||
export function reflectionCoachMutationGateService(db: Db) {
|
||||
return {
|
||||
assertAllowed: async (input: {
|
||||
companyId: string;
|
||||
actorAgentId: string | null | undefined;
|
||||
actorRunId: string | null | undefined;
|
||||
targetKeys: string[];
|
||||
}): Promise<boolean> => {
|
||||
const actorAgentId = readNonEmptyString(input.actorAgentId);
|
||||
if (!actorAgentId) return false;
|
||||
|
||||
const actorAgent = await db
|
||||
.select({
|
||||
id: agents.id,
|
||||
companyId: agents.companyId,
|
||||
metadata: agents.metadata,
|
||||
})
|
||||
.from(agents)
|
||||
.where(eq(agents.id, actorAgentId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
|
||||
if (!actorAgent || actorAgent.companyId !== input.companyId) {
|
||||
throw forbidden("Agent key cannot access another company");
|
||||
}
|
||||
|
||||
const marker = readBuiltInAgentMarker(actorAgent.metadata);
|
||||
if (marker?.key !== "reflection-coach") return false;
|
||||
|
||||
const actorRunId = readNonEmptyString(input.actorRunId);
|
||||
if (!actorRunId) {
|
||||
throw forbidden("Reflection Coach mutations require a run id", {
|
||||
code: "reflection_coach_mutation_run_id_required",
|
||||
});
|
||||
}
|
||||
|
||||
const targetKeys = [...new Set(input.targetKeys.map(readNonEmptyString).filter((key): key is string => Boolean(key)))];
|
||||
if (targetKeys.length === 0) {
|
||||
throw forbidden("Reflection Coach mutation target is not gateable", {
|
||||
code: "reflection_coach_mutation_target_required",
|
||||
});
|
||||
}
|
||||
|
||||
const targetKeyPredicate = or(
|
||||
...targetKeys.map((targetKey) =>
|
||||
sql`${issueThreadInteractions.payload}->'target'->>'key' = ${targetKey}`,
|
||||
),
|
||||
);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: issueThreadInteractions.id,
|
||||
sourceRunId: issueThreadInteractions.sourceRunId,
|
||||
payload: issueThreadInteractions.payload,
|
||||
result: issueThreadInteractions.result,
|
||||
})
|
||||
.from(issueThreadInteractions)
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.companyId, input.companyId),
|
||||
eq(issueThreadInteractions.createdByAgentId, actorAgentId),
|
||||
eq(issueThreadInteractions.kind, "request_confirmation"),
|
||||
eq(issueThreadInteractions.status, "accepted"),
|
||||
targetKeyPredicate,
|
||||
))
|
||||
.orderBy(desc(issueThreadInteractions.resolvedAt), desc(issueThreadInteractions.createdAt))
|
||||
.limit(10);
|
||||
|
||||
const accepted = rows.find((row) => {
|
||||
const payload = row.payload as RequestConfirmationPayload;
|
||||
const result = row.result as RequestConfirmationResult | null;
|
||||
return payload.target?.type === "custom"
|
||||
&& targetKeys.includes(payload.target.key)
|
||||
&& result?.outcome === "accepted"
|
||||
&& payloadHasDisplayedDiff(payload)
|
||||
&& Boolean(row.sourceRunId)
|
||||
&& row.sourceRunId !== actorRunId;
|
||||
});
|
||||
|
||||
if (!accepted) {
|
||||
throw forbidden(
|
||||
"Reflection Coach mutations require an accepted request_confirmation with a displayed diff for this target, "
|
||||
+ "created in a previous run.",
|
||||
{
|
||||
code: "reflection_coach_mutation_gate_required",
|
||||
targetKeys,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user