diff --git a/server/src/__tests__/agent-conversations.test.ts b/server/src/__tests__/agent-conversations.test.ts index deb40dbbd72..b926c65c561 100644 --- a/server/src/__tests__/agent-conversations.test.ts +++ b/server/src/__tests__/agent-conversations.test.ts @@ -994,6 +994,11 @@ describe("chat prompt policy", () => { expect(prompt).toContain("//agents/"); }); + it("tells chat where a person can hire when the agent cannot", () => { + expect(AGENT_CHAT_DIRECTIVE).toContain("send the person to the hire form at //agents/new"); + expect(AGENT_CHAT_DIRECTIVE).toContain("to your own /permissions page, where they can turn on hiring for you"); + }); + it("tells chat to link the agent profile page that holds a control, including the unlisted API keys page", () => { expect(AGENT_CHAT_DIRECTIVE).toContain("//agents//api-keys for API keys"); expect(AGENT_CHAT_DIRECTIVE).toContain("The API keys page is not in the profile's navigation"); diff --git a/server/src/__tests__/agent-permissions-routes.test.ts b/server/src/__tests__/agent-permissions-routes.test.ts index d670a5ac301..866f5d5734b 100644 --- a/server/src/__tests__/agent-permissions-routes.test.ts +++ b/server/src/__tests__/agent-permissions-routes.test.ts @@ -1277,6 +1277,42 @@ describe("agent permission routes", () => { expect(mockLogActivity).not.toHaveBeenCalled(); }); + it.each(["agents", "agent-hires"])("tells a denied agent that its hiring denial is final and where a person can act via %s", async (route) => { + mockAccessService.decide.mockImplementation(async (input: { action?: string }) => ({ + allowed: input.action !== "agents:create", + action: input.action, + reason: input.action === "agents:create" ? "deny_missing_grant" : "allow_test", + explanation: input.action === "agents:create" ? "Missing permission: agents:create." : "Allowed in test.", + })); + const app = createApp({ type: "agent", agentId, companyId, source: "agent_jwt" }); + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .post(`/api/companies/${companyId}/${route}`) + .send({ name: "Morgan", role: "qa", adapterType: "process", adapterConfig: {} })); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toContain("Missing permission: agents:create."); + expect(res.body.error).toContain("no other route overrides this denial"); + expect(res.body.error).toContain("A board user can hire the agent directly"); + expect(mockAgentService.create).not.toHaveBeenCalled(); + expect(mockApprovalService.create).not.toHaveBeenCalled(); + }); + + it("keeps the plain denial on agents:create checks that are not hiring", async () => { + mockAccessService.decide.mockImplementation(async (input: { action?: string }) => ({ + allowed: input.action !== "agents:create", + action: input.action, + reason: input.action === "agents:create" ? "deny_missing_grant" : "allow_test", + explanation: input.action === "agents:create" ? "Missing permission: agents:create." : "Allowed in test.", + })); + const app = createApp({ type: "agent", agentId, companyId, source: "agent_jwt" }); + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .post(`/api/companies/${companyId}/adapters/process/test-environment`) + .send({ adapterConfig: {} })); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toBe("Missing permission: agents:create."); + }); + it.each(["agents", "agent-hires"])("rejects agent-supplied Codex host commands before resolving the runner via %s", async (route) => { mockAccessService.hasPermission.mockResolvedValue(true); mockInstanceSettingsService.get.mockRejectedValue(new Error("Denied requests must not inspect execution targets")); @@ -2151,6 +2187,76 @@ describe("agent permission routes", () => { expect(res.body.permissions.canCreateSkills).toBe(false); }); + it("does not let a CEO denied hiring restore it for itself or grant it to another agent", async () => { + const ceo = { ...baseAgent, id: "ceo-agent", role: "ceo", permissions: { canCreateAgents: false, canCreateSkills: true, + ceoHiringDeniedBy: { actorType: "user", actorId: "board-user", at: "2026-11-08T00:00:00.000Z" } } }; + const worker = { ...baseAgent, permissions: { canCreateAgents: false, canCreateSkills: true } }; + mockAgentService.getById.mockImplementation(async (id: string) => (id === ceo.id ? ceo : id === worker.id ? worker : null)); + mockAccessService.decide.mockImplementation(async (input: { action?: string }) => ({ + allowed: input.action !== "agents:create", + action: input.action, + reason: input.action === "agents:create" ? "deny_missing_grant" : "allow_test", + explanation: input.action === "agents:create" ? "Missing permission: agents:create." : "Allowed in test.", + })); + const app = createApp({ type: "agent", agentId: ceo.id, companyId, runId: "run-1", source: "agent_jwt" }); + + for (const target of [ceo.id, worker.id]) { + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .patch(`/api/agents/${target}/permissions`) + .send({ canCreateAgents: true, canAssignTasks: true })); + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toContain("Missing permission: agents:create."); + } + expect(mockAgentService.updatePermissions).not.toHaveBeenCalled(); + expect(mockAccessService.setPrincipalPermission).not.toHaveBeenCalled(); + }); + + it("records the board user who saves a CEO's hiring as off", async () => { + const ceo = { ...baseAgent, role: "ceo", permissions: { canCreateAgents: true, canCreateSkills: true } }; + mockAgentService.getById.mockResolvedValue(ceo); + mockAgentService.updatePermissions.mockResolvedValue({ ...ceo, permissions: { canCreateAgents: false, canCreateSkills: true } }); + const app = await createApp({ type: "board", userId: "board-user", source: "local_implicit", isInstanceAdmin: true, companyIds: [companyId] }); + + const res = await requestApp(app, (baseUrl) => request(baseUrl) + .patch(`/api/agents/${agentId}/permissions`) + .send({ canCreateAgents: false, canAssignTasks: true })); + expect(res.status, JSON.stringify(res.body)).toBe(200); + expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, expect.objectContaining({ + canCreateAgents: false, + ceoHiringDeniedBy: expect.objectContaining({ actorType: "user", actorId: "board-user", at: expect.any(String) }), + })); + + // The toggle is authoritative: turning it off also removes an explicit agents:create grant. + expect(mockAccessService.setPrincipalPermission).toHaveBeenCalledWith(companyId, "agent", agentId, "agents:create", false, null); + + // A low-trust CEO moved to standard trust while the visible toggle stays off is recorded too. + mockAgentService.updatePermissions.mockClear(); + mockAgentService.getById.mockResolvedValue({ ...ceo, permissions: { canCreateAgents: false, trustPreset: "low_trust_review" } }); + await requestApp(app, (baseUrl) => request(baseUrl) + .patch(`/api/agents/${agentId}/permissions`) + .send({ canCreateAgents: false, canAssignTasks: true, trustPreset: "standard" })); + expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, expect.objectContaining({ + trustPreset: "standard", canCreateAgents: false, ceoHiringDeniedBy: expect.objectContaining({ actorId: "board-user" }), + })); + + // An earlier record is kept rather than restamped. + const earlier = { actorType: "user", actorId: "first-board-user", at: "2026-11-01T00:00:00.000Z" }; + mockAgentService.updatePermissions.mockClear(); + mockAgentService.getById.mockResolvedValue({ ...ceo, permissions: { canCreateAgents: false, ceoHiringDeniedBy: earlier } }); + await requestApp(app, (baseUrl) => request(baseUrl) + .patch(`/api/agents/${agentId}/permissions`) + .send({ canCreateAgents: false, canCreateSkills: false, canAssignTasks: true })); + expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, expect.objectContaining({ ceoHiringDeniedBy: earlier })); + + // Other roles get no record. + mockAgentService.updatePermissions.mockClear(); + mockAgentService.getById.mockResolvedValue({ ...baseAgent, permissions: { canCreateAgents: true } }); + await requestApp(app, (baseUrl) => request(baseUrl) + .patch(`/api/agents/${agentId}/permissions`) + .send({ canCreateAgents: false, canAssignTasks: true })); + expect(mockAgentService.updatePermissions).toHaveBeenCalledWith(agentId, { canCreateAgents: false, canAssignTasks: true }); + }); + it("rejects CEO permission updates outside the caller company scope", async () => { const app = await createApp({ type: "agent", diff --git a/server/src/__tests__/agent-permissions-service.test.ts b/server/src/__tests__/agent-permissions-service.test.ts index e45ecb944a3..24f071df571 100644 --- a/server/src/__tests__/agent-permissions-service.test.ts +++ b/server/src/__tests__/agent-permissions-service.test.ts @@ -29,6 +29,19 @@ describe("agent permissions service", () => { expect(normalizeAgentPermissions([]).canCreateAgents).toBe(false); }); + it("reports a CEO's hiring authority unless the board recorded a denial", () => { + expect(normalizeAgentPermissions({}, { role: "ceo" }).canCreateAgents).toBe(true); + expect(normalizeAgentPermissions(undefined, { role: "ceo" }).canCreateAgents).toBe(true); + expect(normalizeAgentPermissions({ canCreateAgents: false }, { role: "ceo" }).canCreateAgents).toBe(true); + const denial = { actorType: "user", actorId: "board-user", at: "2026-11-08T00:00:00.000Z" }; + expect(normalizeAgentPermissions({ canCreateAgents: false, ceoHiringDeniedBy: denial }, { role: "ceo" })) + .toMatchObject({ canCreateAgents: false, ceoHiringDeniedBy: denial }); + expect(normalizeAgentPermissions({ canCreateAgents: true, ceoHiringDeniedBy: denial }, { role: "ceo" })).not.toHaveProperty("ceoHiringDeniedBy"); + expect(normalizeAgentPermissions({ canCreateAgents: false, ceoHiringDeniedBy: { actorType: "forged" } }, { role: "ceo" }).canCreateAgents).toBe(true); + expect(normalizeAgentPermissions({ trustPreset: LOW_TRUST_REVIEW_PRESET }, { role: "ceo" }).canCreateAgents).toBe(false); + expect(normalizeAgentPermissions({}, { role: "engineer" }).canCreateAgents).toBe(false); + }); + it("withholds agent-creation authority from new low-trust agents", () => { expect(defaultAgentPermissions({ lowTrust: true, context: "create" }).canCreateAgents).toBe(false); expect( diff --git a/server/src/__tests__/agents-pending-approval-config.test.ts b/server/src/__tests__/agents-pending-approval-config.test.ts index ea7c38d97ad..7079fb40e99 100644 --- a/server/src/__tests__/agents-pending-approval-config.test.ts +++ b/server/src/__tests__/agents-pending-approval-config.test.ts @@ -187,4 +187,35 @@ describeEmbeddedPostgres("pending approval agent config integrity", () => { metadata: { source: "hire-form" }, }); }); + + it("keeps a CEO's hiring authority as saved when another permission changes", async () => { + const companyId = await seedCompany(); + const service = agentService(db); + const insertCeo = (permissions: Record) => db.insert(agents).values({ + companyId, name: `CEO ${randomUUID()}`, role: "ceo", permissions, + adapterType: "process", adapterConfig: {}, runtimeConfig: {}, + }).returning().then(rows => rows[0]!); + const stored = (id: string) => db.select().from(agents).where(eq(agents.id, id)).then(rows => rows[0]!.permissions as Record); + + const legacy = await insertCeo({}); + await service.updatePermissions(legacy.id, { canCreateSkills: false } as never); + expect(await stored(legacy.id)).toMatchObject({ canCreateAgents: true, canCreateSkills: false }); + + const unrecorded = await insertCeo({ canCreateAgents: false, canCreateSkills: true }); + await service.updatePermissions(unrecorded.id, { canCreateSkills: false } as never); + expect(await stored(unrecorded.id)).toMatchObject({ canCreateAgents: true, canCreateSkills: false }); + + const denial = { actorType: "user", actorId: "board-user", at: "2026-11-08T00:00:00.000Z" }; + const denied = await insertCeo({ canCreateAgents: false, canCreateSkills: true, ceoHiringDeniedBy: denial }); + await service.updatePermissions(denied.id, { canCreateSkills: false } as never); + expect(await stored(denied.id)).toMatchObject({ canCreateAgents: false, canCreateSkills: false, ceoHiringDeniedBy: denial }); + + // An import update whose manifest omits the field keeps the board's denial, + // and a supplied denial record is never taken from input. + await service.update(denied.id, { permissions: {} } as never); + expect(await stored(denied.id)).toMatchObject({ canCreateAgents: false, ceoHiringDeniedBy: denial }); + const open = await insertCeo({ canCreateAgents: true }); + await service.update(open.id, { permissions: { canCreateAgents: false, ceoHiringDeniedBy: denial } } as never); + expect(await stored(open.id)).not.toHaveProperty("ceoHiringDeniedBy"); + }); }); diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index d85561ea414..fadfe9bf61d 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -2065,6 +2065,44 @@ describeEmbeddedPostgres("authorization service", () => { }); }); + it("honors a board-recorded hiring denial on a CEO instead of the CEO title", async () => { + const company = await createCompany(db, "CeoHireDenied"); + const actorAgent = await createAgent(db, company.id, { + role: "ceo", + permissions: { canCreateAgents: false, canCreateSkills: true, ceoHiringDeniedBy: { actorType: "user", actorId: "board-user", at: "2026-11-08T00:00:00.000Z" } }, + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_jwt" }, + action: "agents:create", + resource: { type: "company", companyId: company.id }, + }); + + expect(decision).toMatchObject({ + allowed: false, + reason: "deny_missing_grant", + explanation: "Missing permission: agents:create.", + }); + }); + + it("keeps CEO hiring when a false was saved without a board-recorded denial", async () => { + // Stale full-object saves, import updates and the low-trust default can + // all save false on a CEO without the board choosing to restrict it. + const company = await createCompany(db, "CeoUnrecordedFalse"); + const actorAgent = await createAgent(db, company.id, { + role: "ceo", + permissions: { canCreateAgents: false, canCreateSkills: true }, + }); + + const decision = await authorizationService(db).decide({ + actor: { type: "agent", agentId: actorAgent.id, companyId: company.id, source: "agent_jwt" }, + action: "agents:create", + resource: { type: "company", companyId: company.id }, + }); + + expect(decision).toMatchObject({ allowed: true, reason: "allow_legacy_agent_creator" }); + }); + it("grants hire authority through the persisted new-agent default", async () => { const company = await createCompany(db, "DefaultHire"); // The service create path persists the create-context default diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index bf01cb22b23..7dc7400a1d5 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -1268,10 +1268,31 @@ describe("company portability routes", () => { expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith( { ...importRequest, pauseAutomations: true }, null, - { mode: "agent_safe", sourceCompanyId: companyId, pauseAutomations: true }, + { mode: "agent_safe", sourceCompanyId: companyId, pauseAutomations: true, assertCanCreateAgents: expect.any(Function) }, ); }); + it("refuses a CEO agent whose hiring is denied when its import would create agents", async () => { + (mockAccessService as Record).decide = vi.fn(async () => ({ + allowed: false, action: "agents:create", reason: "deny_missing_grant", explanation: "Missing permission: agents:create.", + })); + mockCompanyPortabilityService.importBundle.mockImplementationOnce(async (_body: unknown, _user: unknown, options: { assertCanCreateAgents?: () => Promise }) => { + await options.assertCanCreateAgents?.(); + return createImportResult("created"); + }); + const app = await createApp({ type: "agent", agentId: ceoAgentId, companyId, source: "agent_key", runId: "run-1" }); + + const res = await request(app).post(`/api/companies/${companyId}/imports/apply`).send(importRequest); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + expect(res.body.error).toContain("Missing permission: agents:create."); + expect(res.body.error).toContain("Importing new agents is hiring"); + expect((mockAccessService as unknown as { decide: ReturnType }).decide).toHaveBeenCalledWith(expect.objectContaining({ + action: "agents:create", resource: { type: "company", companyId }, + })); + expect(mockLogActivity).not.toHaveBeenCalled(); + }); + it("imports a company from a multipart zip upload, unzipping into the same inline bundle", async () => { const app = await createBoardApp(); const files = { "COMPANY.md": "---\nname: Test\n---\n", "agents/ceo/AGENTS.md": "---\nname: CEO\n---\n" }; diff --git a/server/src/__tests__/company-portability.test.ts b/server/src/__tests__/company-portability.test.ts index 58c72c0a128..347d01c29c9 100644 --- a/server/src/__tests__/company-portability.test.ts +++ b/server/src/__tests__/company-portability.test.ts @@ -31,6 +31,7 @@ const agentSvc = { getById: vi.fn(), create: vi.fn(), update: vi.fn(), + updatePermissions: vi.fn(), }; const accessSvc = { @@ -653,6 +654,26 @@ describe("company portability", () => { expect(asTextFile(exported.files["agents/claudecoder/AGENTS.md"])).toContain(`- "${paperclipKey}"`); }); + it("exports a CEO's recorded hiring denial and drops an unrecorded false", async () => { + const denial = { actorType: "user", actorId: "board-user", at: "2026-11-08T00:00:00.000Z" }; + const ceo = (id: string, name: string, permissions: Record) => ({ + id, companyId: "company-1", name, status: "idle", role: "ceo", title: null, icon: null, reportsTo: null, capabilities: null, + adapterType: "codex_local", adapterConfig: {}, runtimeConfig: {}, budgetMonthlyCents: 0, permissions, metadata: null, + }); + agentSvc.list.mockResolvedValue([ + ceo("denied-ceo", "DeniedCeo", { canCreateAgents: false, canCreateSkills: true, ceoHiringDeniedBy: denial }), + ceo("open-ceo", "OpenCeo", { canCreateAgents: true, canCreateSkills: true }), + ]); + + const exported = await companyPortabilityService({} as any).exportBundle("company-1", { + include: { company: true, agents: true, projects: false, issues: false, skills: false }, + }); + + const permissions = (slug: string) => exported.manifest.agents.find((agent: { slug: string }) => agent.slug === slug)?.permissions; + expect(permissions("deniedceo")).toMatchObject({ canCreateAgents: false, ceoHiringDeniedBy: denial }); + expect(permissions("openceo")).not.toHaveProperty("ceoHiringDeniedBy"); + }); + it("refuses to read external instruction roots without an instance-admin export grant", async () => { agentSvc.list.mockResolvedValue([{ id: "external-agent", @@ -1792,6 +1813,64 @@ describe("company portability", () => { for (const [, input] of agentSvc.create.mock.calls) expect(input.appearance).toEqual(appearance); }); + it("restores a CEO's recorded hiring denial on import, stamped by the importing user", async () => { + const denial = { actorType: "user", actorId: "source-board-user", at: "2026-11-08T00:00:00.000Z" }; + const source = await agentSvc.list(); + agentSvc.list.mockResolvedValue([{ ...source[0], role: "ceo", permissions: { canCreateAgents: false, canCreateSkills: true, ceoHiringDeniedBy: denial } }]); + const portability = companyPortabilityService({} as any); + const include = { company: true, agents: true, projects: false, issues: false, skills: false }; + const exported = await portability.exportBundle("company-1", { include }); + + agentSvc.list.mockResolvedValue([]); + agentSvc.create.mockImplementation(async (_companyId: string, input: Record) => ({ ...input, id: `imported-${input.name}` })); + agentSvc.updatePermissions.mockImplementation(async (id: string, permissions: Record) => ({ id, role: "ceo", permissions })); + await portability.importBundle({ source: { type: "inline", files: exported.files, rootPath: exported.rootPath }, include, + target: { mode: "new_company", newCompanyName: "Imported CEO" }, collisionStrategy: "rename", agents: "all" }, "user-1"); + + expect(agentSvc.updatePermissions).toHaveBeenCalledWith(expect.stringMatching(/^imported-/), { + canCreateAgents: false, + ceoHiringDeniedBy: expect.objectContaining({ actorType: "user", actorId: "user-1", at: expect.any(String) }), + }); + const importedId = agentSvc.updatePermissions.mock.calls[0]![0]; + expect(accessSvc.setPrincipalPermission).toHaveBeenCalledWith(expect.any(String), "agent", importedId, "agents:create", false, null); + }); + + it("asks the importing actor for hiring authority before an import creates agents", async () => { + const portability = companyPortabilityService({} as any); + const include = { company: true, agents: true, projects: false, issues: false, skills: false }; + const exported = await portability.exportBundle("company-1", { include }); + agentSvc.list.mockResolvedValue([]); + const assertCanCreateAgents = vi.fn(async () => { throw new Error("hiring denied"); }); + + await expect(portability.importBundle({ source: { type: "inline", files: exported.files, rootPath: exported.rootPath }, include, + target: { mode: "new_company", newCompanyName: "Denied import" }, collisionStrategy: "rename", agents: "all" }, null, + { mode: "agent_safe", assertCanCreateAgents })).rejects.toThrow("hiring denied"); + expect(assertCanCreateAgents).toHaveBeenCalledTimes(1); + expect(agentSvc.create).not.toHaveBeenCalled(); + }); + + it("removes an existing hiring grant when an update import restores a CEO's denial", async () => { + const denial = { actorType: "user", actorId: "source-board-user", at: "2026-11-08T00:00:00.000Z" }; + const [fixture] = await agentSvc.list(); + const existing = { ...fixture, role: "ceo", permissions: { canCreateAgents: false, canCreateSkills: true, ceoHiringDeniedBy: denial } }; + agentSvc.list.mockResolvedValue([existing]); + const portability = companyPortabilityService({} as any); + const exported = await portability.exportBundle("company-1", { include: { company: false, agents: true, projects: false, issues: false } }); + agentSvc.update.mockImplementation(async (id: string, input: Record) => ({ ...existing, ...input, id })); + agentSvc.updatePermissions.mockImplementation(async (id: string, permissions: Record) => ({ ...existing, id, permissions })); + accessSvc.setPrincipalPermission.mockClear(); + await portability.importBundle({ source: { type: "inline", rootPath: exported.rootPath, files: exported.files }, + include: { company: false, agents: true, projects: false, issues: false }, target: { mode: "existing_company", companyId: "company-1" }, + agents: "all", collisionStrategy: "replace" }, "user-1"); + + expect(agentSvc.create).not.toHaveBeenCalled(); + expect(agentSvc.updatePermissions).toHaveBeenCalledWith("agent-1", expect.objectContaining({ canCreateAgents: false })); + expect(accessSvc.setPrincipalPermission).toHaveBeenCalledWith("company-1", "agent", "agent-1", "agents:create", false, null); + expect(accessSvc.setPrincipalPermission).not.toHaveBeenCalledWith("company-1", "agent", "agent-1", "agents:create", true, expect.anything(), expect.anything()); + agentSvc.update.mockReset(); + agentSvc.updatePermissions.mockReset(); + }); + it("reads env inputs back from .paperclip.yaml during preview import", async () => { const portability = companyPortabilityService({} as any); diff --git a/server/src/lib/agent-permissions.ts b/server/src/lib/agent-permissions.ts index 15e4fe64695..dece59f1f08 100644 --- a/server/src/lib/agent-permissions.ts +++ b/server/src/lib/agent-permissions.ts @@ -68,33 +68,57 @@ export function permissionsImplyLowTrust(permissions: unknown): boolean { export type AgentPermissionsContext = "create" | "stored"; export function defaultAgentPermissions( - options?: { lowTrust?: boolean; context?: AgentPermissionsContext }, + options?: { lowTrust?: boolean; context?: AgentPermissionsContext; role?: string | null }, ): NormalizedAgentPermissions { return { - canCreateAgents: options?.context === "create" && options?.lowTrust !== true, + // A CEO row without a recorded hiring restriction keeps its legacy hiring + // authority, so reads report what enforcement allows. + canCreateAgents: (options?.context === "create" || options?.role === "ceo") && options?.lowTrust !== true, canCreateSkills: true, }; } +/** + * Records who turned off a CEO's hiring through the permissions control. + * Several ordinary writes save canCreateAgents: false without meaning to + * restrict a CEO: a full-object save of a stale read, an import update whose + * manifest omits the field, and the low-trust default. So a saved false + * restricts a CEO only together with this record. + */ +export const CEO_HIRING_DENIAL_KEY = "ceoHiringDeniedBy"; +export type CeoHiringDenial = { actorType: "user" | "agent"; actorId: string | null; at: string }; + +export function ceoHiringDenial(permissions: unknown): CeoHiringDenial | null { + const denial = asRecord(asRecord(permissions)?.[CEO_HIRING_DENIAL_KEY]); + return denial && (denial.actorType === "user" || denial.actorType === "agent") && typeof denial.at === "string" + ? { actorType: denial.actorType, actorId: typeof denial.actorId === "string" ? denial.actorId : null, at: denial.at } + : null; +} + export function normalizeAgentPermissions( permissions: unknown, - options?: { context?: AgentPermissionsContext }, + options?: { context?: AgentPermissionsContext; role?: string | null }, ): NormalizedAgentPermissions { + const lowTrust = permissionsImplyLowTrust(permissions); const defaults = defaultAgentPermissions({ - lowTrust: permissionsImplyLowTrust(permissions), + lowTrust, context: options?.context ?? "stored", + role: options?.role, }); const record = asRecord(permissions); if (!record) { return defaults; } + const { [CEO_HIRING_DENIAL_KEY]: _denial, ...rest } = record; + const denial = ceoHiringDenial(record); + let canCreateAgents = typeof record.canCreateAgents === "boolean" ? record.canCreateAgents : defaults.canCreateAgents; + // An unrecorded false on a CEO is not a restriction the board chose. + if (options?.role === "ceo" && canCreateAgents === false && !denial) canCreateAgents = !lowTrust; return { - ...record, - canCreateAgents: - typeof record.canCreateAgents === "boolean" - ? record.canCreateAgents - : defaults.canCreateAgents, + ...rest, + ...(canCreateAgents === false && denial ? { [CEO_HIRING_DENIAL_KEY]: denial } : {}), + canCreateAgents, canCreateSkills: typeof record.canCreateSkills === "boolean" ? record.canCreateSkills diff --git a/server/src/lib/agent-records.ts b/server/src/lib/agent-records.ts index f50b1c3df8a..b9a7bb702f7 100644 --- a/server/src/lib/agent-records.ts +++ b/server/src/lib/agent-records.ts @@ -330,7 +330,7 @@ export function agentRecordQueries(db: Db) { const { lifecycleOperation, lifecycleRequiredPluginIds, harnessVerifiedDigest, harnessVerifiedAt, ...publicRow } = row; return withUrlKey({ ...publicRow, - permissions: normalizeAgentPermissions(row.permissions), + permissions: normalizeAgentPermissions(row.permissions, { role: row.role }), }); } diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 7f338f50416..1c2de8ccc7a 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -5,6 +5,7 @@ import { enqueueNativeRuntimeResponse } from "../services/native-runtime/native- import { latestFailedRunsByAgent } from "@paperclipai/shared"; import { agentConfigurationSpec } from "@paperclipai/shared"; import { agentConfigurationRevision, restoreAgentConfigurationRedactions } from "../lib/agent-configuration-revision.js"; +import { CEO_HIRING_DENIAL_KEY, ceoHiringDenial, normalizeAgentPermissions } from "../lib/agent-permissions.js"; import { prepareRuntimeSettingsPatch } from "../services/agent-runtime-settings.js"; import { completeSetupTokenAiConnection } from "../services/setup-token-ai-connection.js"; import { connectionManagementService, continuingConnectionManagement } from "../services/connection-management.js"; @@ -1417,7 +1418,7 @@ export function agentRoutes( ); } - async function assertCanCreateAgentsForCompany(req: Request, companyId: string) { + async function assertCanCreateAgentsForCompany(req: Request, companyId: string, options?: { hiring?: true }) { assertCompanyAccess(req, companyId); const decision = await access.decide({ actor: req.actor, @@ -1425,7 +1426,12 @@ export function agentRoutes( resource: { type: "company", companyId }, }); if (!decision.allowed) { - throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); + // An agent denied hiring otherwise searches other routes for an override. + // Say the denial is final and where a person can act instead. + const explanation = options?.hiring && req.actor.type === "agent" && decision.reason === "deny_missing_grant" + ? `${decision.explanation} This agent cannot hire or create agents, and no other route overrides this denial. A board user can hire the agent directly, or turn on hiring for this agent on its permissions page.` + : decision.explanation; + throw forbidden(explanation, authorizationDeniedDetails(decision)); } if (req.actor.type !== "agent") return null; const actorAgent = req.actor.agentId ? await svc.getById(req.actor.agentId) : null; @@ -4440,7 +4446,7 @@ export function agentRoutes( const handleAgentHire = async (req: Request, res: Response) => { const companyId = req.params.companyId as string; - await assertCanCreateAgentsForCompany(req, companyId); + await assertCanCreateAgentsForCompany(req, companyId, { hiring: true }); const sourceIssueIds = parseSourceIssueIds(req.body); const { desiredSkills: requestedDesiredSkills, @@ -4824,7 +4830,7 @@ export function agentRoutes( router.post("/companies/:companyId/agents", validate(createAgentSchema), async (req, res) => { const companyId = req.params.companyId as string; - await assertCanCreateAgentsForCompany(req, companyId); + await assertCanCreateAgentsForCompany(req, companyId, { hiring: true }); const company = await db .select() @@ -5041,11 +5047,28 @@ export function agentRoutes( res.status(403).json({ error: "Only CEO can manage permissions" }); return; } + // Hiring authority is granted only by an actor that holds it. A CEO the + // board denied hiring must not restore it for itself or pass it to + // another agent to hire through. + const savedCanCreateAgents = normalizeAgentPermissions(existing.permissions, { role: existing.role }).canCreateAgents; + if (typeof req.body.canCreateAgents === "boolean" && req.body.canCreateAgents !== savedCanCreateAgents) { + await assertCanCreateAgentsForCompany(req, existing.companyId, { hiring: true }); + } } else { await assertBoardCanManageAgentsForCompany(req, existing.companyId); } - const agent = await svc.updatePermissions(id, req.body); + // Saving a CEO's hiring as off here is the board's restriction, since this + // control shows the effective value; record who and when, so it is told + // apart from a false saved by other writes. Keep an earlier record. + const permissionsPatch = existing.role === "ceo" && req.body.canCreateAgents === false + ? { ...req.body, [CEO_HIRING_DENIAL_KEY]: ceoHiringDenial(existing.permissions) ?? { + actorType: req.actor.type === "agent" ? "agent" : "user", + actorId: (req.actor.type === "agent" ? req.actor.agentId : req.actor.userId) ?? null, + at: new Date().toISOString(), + } } + : req.body; + const agent = await svc.updatePermissions(id, permissionsPatch); if (!agent) { res.status(404).json({ error: "Agent not found" }); return; @@ -5054,6 +5077,11 @@ export function agentRoutes( const effectiveCanAssignTasks = agent.role === "ceo" || Boolean(agent.permissions?.canCreateAgents) || req.body.canAssignTasks; await access.ensureMembership(agent.companyId, "agent", agent.id, "member", "active"); + // The toggle is the visible hiring control: turning it off also removes an + // explicit agents:create grant that would otherwise still allow hiring. + if (req.body.canCreateAgents === false) { + await access.setPrincipalPermission(agent.companyId, "agent", agent.id, "agents:create", false, null); + } await access.setPrincipalPermission( agent.companyId, "agent", diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 768c003e787..8f80323ec3b 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -35,6 +35,7 @@ import { type CompanyImportTransferStatus, } from "@paperclipai/shared/company-import-transfer"; import { badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js"; +import { authorizationDeniedDetails } from "../services/authorization.js"; import { PORTABLE_ZIP_UPLOAD_LIMIT_BYTES } from "../http/body-limits.js"; import { logger } from "../middleware/logger.js"; import { validate } from "../middleware/validate.js"; @@ -1172,6 +1173,12 @@ export function companyRoutes(db: Db, storage?: StorageService, options?: Compan mode: "agent_safe", sourceCompanyId: companyId, pauseAutomations: body.pauseAutomations === true, + // An agent importing new agents is hiring, so it needs agents:create + // like the hire routes: the CEO role does not override a denial. + ...(req.actor.type === "agent" ? { assertCanCreateAgents: async () => { + const decision = await access.decide({ actor: req.actor, action: "agents:create", resource: { type: "company", companyId } }); + if (!decision.allowed) throw forbidden(`${decision.explanation} Importing new agents is hiring; a board user can import them instead.`, authorizationDeniedDetails(decision)); + } } : {}), }); await logActivity(db, { companyId: result.company.id, diff --git a/server/src/services/agent-configuration.ts b/server/src/services/agent-configuration.ts index 43feffbd4fa..3dfc40e4048 100644 --- a/server/src/services/agent-configuration.ts +++ b/server/src/services/agent-configuration.ts @@ -14,7 +14,7 @@ import { normalizePaperclipRunnerAdapterConfig } from "@paperclipai/adapter-util import { conflict, notFound, unprocessable } from "../errors.js"; import { agentRecordQueries, type UpdateAgentOptions, type AgentConfigurationPatch, type AgentConfigurationRecord, isPlainRecord, buildConfigSnapshot, hasConfigPatchFields, changedPendingApprovalConfigFields, diffConfigSnapshot, jsonEqual } from "../lib/agent-records.js"; -import { normalizeAgentPermissions } from "../lib/agent-permissions.js"; +import { CEO_HIRING_DENIAL_KEY, ceoHiringDenial, normalizeAgentPermissions } from "../lib/agent-permissions.js"; import type { ActivityPublication } from "../types/activity-publication.js"; import { secretService, assertClaudeOAuthBindingInvariant } from "./secrets.js"; import { agentCredentialService } from "./agent-credentials.js"; @@ -66,7 +66,15 @@ export async function prepareAgentConfiguration(db: Db, existing: AgentConfigura } const normalizedPatch = { ...data } as AgentConfigurationPatch; if (data.permissions !== undefined) { - normalizedPatch.permissions = normalizeAgentPermissions(data.permissions); + // Only the permissions control records a CEO hiring restriction. Keep an + // existing one unless this write turns hiring on, and never take one from input. + const incoming = data.permissions && typeof data.permissions === "object" && !Array.isArray(data.permissions) + ? Object.fromEntries(Object.entries(data.permissions).filter(([key]) => key !== CEO_HIRING_DENIAL_KEY)) : data.permissions; + const denial = ceoHiringDenial(existing.permissions); + normalizedPatch.permissions = normalizeAgentPermissions( + denial && (incoming as Record | null)?.canCreateAgents !== true + ? { ...(incoming as Record), [CEO_HIRING_DENIAL_KEY]: denial } : incoming, + ); } if ( Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig") && diff --git a/server/src/services/agent-conversations.ts b/server/src/services/agent-conversations.ts index e63bfe57f13..0f6cbf19979 100644 --- a/server/src/services/agent-conversations.ts +++ b/server/src/services/agent-conversations.ts @@ -113,7 +113,7 @@ For review or follow-up tasks, include the existing source materials the assigne For task links, use the company-prefixed relative path //issues/. The company prefix is the part of a task identifier before its final hyphen and number: for task RUN-1 the prefix is RUN. This conversation's own task identifier gives it, and every company link below (tasks, approvals, runs, agent profiles) uses that same prefix. Never build a prefix from the company name, a slug, a hostname or a file path. Write every company link as a Markdown link, such as [Review the approval](//approvals/): a bare path or a path in inline code is not clickable in this conversation. Start each link target with exactly one slash: a target beginning with //, such as ///issues/, points to a different website. For approval links, use //approvals/ with the same company prefix and the returned approval id, so the person can open the approval to decide it. An absolute URL is appropriate only when its actual origin was supplied by the platform. Never invent a hostname for a Paperclip link. For an execution run, link //agents//runs/ using the run's returned agentId. Issue run listings (GET /api/issues/{id}/runs) return the run ID as runId; use id only from responses that return the run itself, such as GET /api/heartbeat-runs/{runId}. Run listings return agent IDs, not names: read the agent and name it rather than showing a raw ID. -When a reply concerns a specific agent, such as one you paused, resumed, configured, or reported on, link its profile at //agents/ using the agent's returned urlKey or id. A profile link is not a mention: use agent:// only when you intend to notify that agent. When you send a person to a control on an agent's profile, link the page that holds it instead of the bare profile: //agents//api-keys for API keys, /identity for identity and instructions, /runtime for harness settings, /secrets for secrets and variables, /permissions for permissions. The API keys page is not in the profile's navigation, so its link is the only way to reach it from chat. +When a reply concerns a specific agent, such as one you paused, resumed, configured, or reported on, link its profile at //agents/ using the agent's returned urlKey or id. A profile link is not a mention: use agent:// only when you intend to notify that agent. When you send a person to a control on an agent's profile, link the page that holds it instead of the bare profile: //agents//api-keys for API keys, /identity for identity and instructions, /runtime for harness settings, /secrets for secrets and variables, /permissions for permissions. When you cannot hire an agent yourself, send the person to the hire form at //agents/new, where they can hire it directly, and to your own /permissions page, where they can turn on hiring for you. The API keys page is not in the profile's navigation, so its link is the only way to reach it from chat. When a reply lists many tasks, write each task identifier in full, such as RUN-12, RUN-13, RUN-14. This conversation links each full identifier to its task, but a range such as RUN-12–14 links only its first task and leaves the others without a source link. When a complete list is too long to read in the reply, save it as a document on this conversation's task and link it, such as [Open work by owner](//issues/#document-), keeping a short summary with the counts in the reply. diff --git a/server/src/services/agent-hiring.ts b/server/src/services/agent-hiring.ts index 48e7ec83489..af0cd70a1f9 100644 --- a/server/src/services/agent-hiring.ts +++ b/server/src/services/agent-hiring.ts @@ -9,7 +9,7 @@ import { eq } from "drizzle-orm"; import { unprocessable } from "../errors.js"; import { agentRecordQueries, isPlainRecord, configPatchFromApprovalPayload, normalizeRuntimeConfigForNewAgent, deduplicateAgentName, type CreateAgentData, type CreateAgentOptions, type AgentConfigurationPatch, type AgentHireRecord } from "../lib/agent-records.js"; -import { normalizeAgentPermissions, permissionsImplyLowTrust, NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS, +import { CEO_HIRING_DENIAL_KEY, normalizeAgentPermissions, permissionsImplyLowTrust, NEW_STANDARD_AGENT_DEFAULT_GRANT_KEYS, newStandardAgentGrantScope } from "../lib/agent-permissions.js"; import { readBuiltInAgentMarker } from "../lib/built-in-agent-metadata.js"; import { secretService, assertClaudeOAuthBindingInvariant } from "./secrets.js"; @@ -44,7 +44,10 @@ export async function prepareAgentHire(db: Db, companyId: string, input: CreateA const uniqueName = deduplicateAgentName(data.name, existingAgents.filter(agent => agent.id !== options?.setupAgentId)); const role = data.role ?? "general"; - const normalizedPermissions = normalizeAgentPermissions(data.permissions, { context: "create" }); + // Only the permissions control records a CEO hiring restriction. + const requestedPermissions = data.permissions && typeof data.permissions === "object" && !Array.isArray(data.permissions) + ? Object.fromEntries(Object.entries(data.permissions).filter(([key]) => key !== CEO_HIRING_DENIAL_KEY)) : data.permissions; + const normalizedPermissions = normalizeAgentPermissions(requestedPermissions, { context: "create" }); const runtimeConfig = normalizeRuntimeConfigForNewAgent(data.runtimeConfig); const adapterType = data.adapterType ?? "process"; const rawAdapterConfig = isPlainRecord(data.adapterConfig) diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 8a5f7551ee2..8c660e74792 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -58,7 +58,7 @@ export function agentService(db: Db, budgetHooks: BudgetServiceHooks = {}) { return tx .update(agents) .set({ - permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }), + permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }, { role: existing.role }), updatedAt: new Date(), }) .where(eq(agents.id, id)) diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 12691864f9a..deee9cabd05 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -212,11 +212,13 @@ function permissionForAction(action: AuthorizationAction): PermissionKey | null } function canCreateAgentsLegacy(agent: { role: string; permissions: unknown }) { - if (agent.role === "ceo") return true; - // Raw agent rows may predate permission normalization; apply the same - // defaults the agent service applies on read so enforcement matches what - // the API reports. - return normalizeAgentPermissions(agent.permissions).canCreateAgents; + // An explicit canCreateAgents value is the saved access policy, including + // for CEOs: the board can turn hiring off on any agent's permissions page, + // and the CEO title must not override that denial. The CEO role only fills + // in hiring authority for rows that never saved an explicit value. Raw rows + // may predate permission normalization; apply the same defaults the agent + // service applies on read so enforcement matches what the API reports. + return normalizeAgentPermissions(agent.permissions, { role: agent.role }).canCreateAgents; } function scopeValueList(value: unknown): string[] { diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 0b41482b0fa..71c35f34052 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -118,6 +118,7 @@ import { validatePaperclipRunnerMuseConfig, } from "./native-runtime/provider-profile.js"; import { managedAgentProfileService } from "./managed-agent-profiles.js"; +import { CEO_HIRING_DENIAL_KEY, ceoHiringDenial } from "../lib/agent-permissions.js"; import { remoteAgentProfileService } from "./remote-agent-profiles.js"; const EXPORT_READ_CONCURRENCY = 8; @@ -763,6 +764,8 @@ type ImportBehaviorOptions = { mode?: ImportMode; sourceCompanyId?: string | null; pauseAutomations?: boolean; + /** Runs before writes when the import would create agents; throws to refuse. */ + assertCanCreateAgents?: () => Promise; }; type AgentLike = { @@ -4265,6 +4268,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { }, ) as Record; const portablePermissions = pruneDefaultLikeValue(agent.permissions ?? {}, { dropFalseBooleans: true }) as Record; + // A CEO's recorded hiring denial is a board choice, so it survives the + // round trip even though export otherwise drops false values. + const exportedHiringDenial = agent.role === "ceo" ? ceoHiringDenial(agent.permissions) : null; + if (exportedHiringDenial) Object.assign(portablePermissions, { canCreateAgents: false, [CEO_HIRING_DENIAL_KEY]: exportedHiringDenial }); const portablePermissionGrants = permissionGrantsByAgentId.get(agent.id) ?? []; const agentEnvInputs = dedupeEnvInputs( envInputs @@ -5265,11 +5272,29 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { // the declared count is the only signal that distinguishes it from a // deliberately small bundle. Reject the fragment rather than importing it. assertInlineSourceComplete(input.source); + // The importing board user brings a CEO's recorded hiring denial with the + // package; create and configuration writes never take one from input. + // Like the permissions toggle, the denial also removes an explicit + // agents:create grant, and the package's grants cannot restore one. + const applyImportedCeoHiringDenial = async (companyId: string, agent: T, permissions: unknown) => { + if (agent.role !== "ceo" || !ceoHiringDenial(permissions)) return null; + const updated = await agents.updatePermissions(agent.id, { canCreateAgents: false, [CEO_HIRING_DENIAL_KEY]: { + actorType: "user", actorId: actorUserId ?? null, at: new Date().toISOString(), + } }) as T | null; + await access.setPrincipalPermission(companyId, "agent", agent.id, "agents:create", false, null); + return updated; + }; + const importedGrants = (agent: { role: string }, manifestAgent: { permissions?: unknown; permissionGrants?: PortableAgentPermissionGrant[] }) => + (manifestAgent.permissionGrants ?? []).filter(grant => + !(agent.role === "ceo" && grant.permissionKey === "agents:create" && ceoHiringDenial(manifestAgent.permissions))); const mode = resolveImportMode(options); const plan = await buildPreview(input, options); if (plan.preview.errors.length > 0) { throw unprocessable(`Import preview has errors: ${plan.preview.errors.join("; ")}`); } + if (options?.assertCanCreateAgents && plan.preview.plan.agentPlans.some((entry) => entry.action === "create")) { + await options.assertCanCreateAgents(); + } if ( mode === "agent_safe" && ( @@ -5704,6 +5729,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { let updated = await agents.update(planAgent.existingAgentId, { ...patch, }); + if (updated) updated = await applyImportedCeoHiringDenial(targetCompany.id, updated, manifestAgent.permissions) ?? updated; if (!updated) { warnings.push(`Skipped update for missing agent ${planAgent.existingAgentId}.`); resultAgents.push({ @@ -5727,7 +5753,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { await applyImportedAgentPermissionGrants( targetCompany.id, updated.id, - manifestAgent.permissionGrants ?? [], + importedGrants(updated, manifestAgent), actorUserId ?? null, ); agentStatusById.set(updated.id, updated.status ?? agentStatusById.get(updated.id) ?? null); @@ -5754,6 +5780,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { ...automationPausePatch, status: pauseAutomations ? "paused" : "idle", }, { createdByUserId: actorUserId }); + created = await applyImportedCeoHiringDenial(targetCompany.id, created, manifestAgent.permissions) ?? created; await access.ensureMembership(targetCompany.id, "agent", created.id, "member", "active"); await access.setPrincipalPermission( targetCompany.id, @@ -5775,7 +5802,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) { await applyImportedAgentPermissionGrants( targetCompany.id, created.id, - manifestAgent.permissionGrants ?? [], + importedGrants(created, manifestAgent), actorUserId ?? null, ); agentStatusById.set(created.id, created.status ?? (pauseAutomations ? "paused" : "idle"));