fix(agents): honor an explicit hiring denial on CEO agents (#20041)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The board controls which agents can hire, with the "Can create
agents" permission on each agent
> - A CEO agent with that permission turned off could still hire,
because the CEO role allowed hiring before the server read the saved
permission
> - In chat, a CEO agent asked to hire then created the agent through
the `hire_agent` tool, against the board's policy
> - This pull request makes the saved permission decide, and uses the
CEO role only for old rows that never saved a value
> - It also tells a denied agent that the denial is final and where a
person can act
> - The benefit is that the permissions page and the server now agree,
and the board can trust a hiring denial

## Linked Issues or Issue Description

**What happened**
A board user turned off "Can create agents" for a CEO agent. Then the
user asked the CEO in chat to hire a QA engineer. The CEO called
`hire_agent` (`POST /api/companies/{companyId}/agent-hires`). The server
returned 201 and created the agent. The legacy creator rule allowed any
agent with `role: "ceo"` before it read `permissions.canCreateAgents`.

**Expected behavior**
An explicit `canCreateAgents: false` denies hiring for every role,
including CEO. The agent gets a 403 that says the denial is final, and
it sends the person to the hire form or to its permissions page.

**Steps to reproduce**
1. Create a company with a CEO agent.
2. On the CEO's permissions page, turn off "Can create agents".
3. In chat with the CEO, send "Hire a QA engineer named Morgan."
4. See that a new agent named Morgan exists.

Found by the chat eval case `agents.hire-denied`. Related: #19614 (chat
eval harness).

## What Changed

- `canCreateAgentsLegacy` (authorization) uses the role-aware permission
normalization. A CEO keeps hiring unless the board recorded a hiring
denial.
- New `ceoHiringDeniedBy` record in `permissions` (`{ actorType,
actorId, at }`). `PATCH /agents/:id/permissions` writes it when an
authorized actor saves a CEO's hiring as off (an earlier record is
kept). The page shows the effective value, so a saved off is the board's
choice. Turning the toggle off also removes an explicit `agents:create`
grant. A saved `canCreateAgents: false` restricts a CEO only together
with this record.
- Reads report the effective value. A CEO row with no value, or with an
unrecorded false, shows hiring on. A CEO with a recorded denial shows it
off.
- Create and hire inputs cannot supply the record. Configuration updates
keep an existing record unless they turn hiring on. Company export keeps
a CEO's recorded denial, and import restores it, stamped by the
importing user.
- `PATCH /agents/:id/permissions`: an agent actor needs `agents:create`
to change `canCreateAgents`. A denied CEO cannot restore hiring for
itself or grant it to another agent.
- `assertCanCreateAgentsForCompany` adds a final, actionable sentence to
an agent's `agents:create` denial on hiring routes only (`POST /agents`,
`POST /agent-hires`, the hiring toggle on `PATCH /permissions`).
- `POST /api/companies/:companyId/imports/apply` (CEO-safe import): an
agent import that would create agents needs an `agents:create` decision
before any write. Before, any CEO agent could create agents this way.
Import also removes an existing `agents:create` grant when it restores a
CEO's denial.
- Chat guidance tells the agent to link `/<prefix>/agents/new` and its
own `/permissions` page when it cannot hire.
- Regression tests: authorization (a recorded denial is honored; an
unrecorded false keeps CEO hiring), normalization, route tests (denial
text, self-escalation, the record is written only when a CEO is turned
off), service tests for the permission and configuration write paths,
chat guidance text.

### Why a recorded denial and not a plain false

These normal paths save `canCreateAgents: false` on a CEO without a
board choice:

- The permissions page posts the full object. Before this change, a
legacy CEO row with `{}` was read as false, so saving any other toggle
stored false.
- A company import that updates an existing agent normalizes the
manifest permissions in the "stored" context. A manifest without the
field stores false. Export drops false values, so this is common.
- Low-trust creation stores false. If the trust preset later changes to
standard, the false remains.

These paths store true or nothing for a CEO: onboarding seed
(`requestHire`, create context: true), teams-catalog and import creation
(create context: true; the bundled catalog sets true only for a CTO),
built-in agents (no CEO roles), migrations and seed data (no writes to
this field).

A plain-false rule would remove hiring from those CEOs. With the record,
existing data keeps today's behavior, and no data migration is
necessary.

## Verification

- `cd server && npx vitest run
src/__tests__/agent-permissions-service.test.ts
src/__tests__/authorization-service.test.ts
src/__tests__/agent-permissions-routes.test.ts
src/__tests__/agent-conversations.test.ts` passes (244 tests).
- `teams-catalog-service`, `company-portability` and `built-in-agents`
tests pass (189 tests).
- Red check: the new authorization test fails without the
`authorization.ts` change.
- `npx tsc --noEmit -p server` passes.
- End to end: the chat eval `agents.hire-denied` on the OpenCode runner.
The baseline run (Actions run 42478870236) created Morgan through
`hire_agent`. With this fix (Actions run 42480936881), the agent got the
403, created no agent, and linked the hire form and its permissions
page. The eval clicked the rendered hire-form link.

## Risks

- Behavior change: a CEO whose hiring the board turns off after this
change can no longer hire.
- A CEO that already has a saved false keeps hiring until a board user
turns it off again on the permissions page. The page now shows such a
CEO as on, so the board can see and change it.
- A request that creates a CEO with `canCreateAgents: false` does not
restrict it. The board must turn hiring off on the permissions page.
- A CEO agent whose hiring is denied can no longer import a package that
creates agents. A board user can still import it.
- Other CEO special cases are not hiring routes: `issues.ts` lets a CEO
link approvals to issues, and `agents.ts` gives a CEO task assignment.
`teams-catalog.ts` and `llms.ts` read the normalized permissions, so
they follow the same effective value.
- The denial text is longer. No existing test compares this route's
exact error text.

## Model Used

- Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code
execution in Claude Code.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-08 20:05:15 -06:00
1 parent a0b9895bbd
commit c0fc7608ee
17 files changed
+421 -29

No files matched your search

@@ -994,6 +994,11 @@ describe("chat prompt policy", () => {
expect(prompt).toContain("/<prefix>/agents/<urlKey or id>");
});
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 /<prefix>/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("/<prefix>/agents/<urlKey or id>/api-keys for API keys");
expect(AGENT_CHAT_DIRECTIVE).toContain("The API keys page is not in the profile's navigation");
@@ -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",
@@ -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(
@@ -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<string, unknown>) => 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<string, unknown>);
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");
});
});
@@ -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
@@ -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<string, unknown>).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<void> }) => {
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<typeof vi.fn> }).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" };
@@ -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<string, unknown>) => ({
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<string, unknown>) => ({ ...input, id: `imported-${input.name}` }));
agentSvc.updatePermissions.mockImplementation(async (id: string, permissions: Record<string, unknown>) => ({ 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<string, unknown>) => ({ ...existing, ...input, id }));
agentSvc.updatePermissions.mockImplementation(async (id: string, permissions: Record<string, unknown>) => ({ ...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);
+33 -9
View File
@@ -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
+1 -1
View File
@@ -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 }),
});
}
+33 -5
View File
@@ -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",
+7
View File
@@ -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,
+10 -2
View File
@@ -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<string, unknown> | null)?.canCreateAgents !== true
? { ...(incoming as Record<string, unknown>), [CEO_HIRING_DENIAL_KEY]: denial } : incoming,
);
}
if (
Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig") &&
+1 -1
View File
@@ -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 /<prefix>/issues/<identifier>. 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](/<prefix>/approvals/<approvalId>): 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 //<prefix>/issues/<identifier>, points to a different website. For approval links, use /<prefix>/approvals/<approvalId> 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 /<prefix>/agents/<agentId>/runs/<runId> 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 /<prefix>/agents/<urlKey or id> 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: /<prefix>/agents/<urlKey or id>/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 /<prefix>/agents/<urlKey or id> 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: /<prefix>/agents/<urlKey or id>/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 /<prefix>/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](/<prefix>/issues/<identifier>#document-<key>), keeping a short summary with the counts in the reply.
+5 -2
View File
@@ -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)
+1 -1
View File
@@ -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))
+7 -5
View File
@@ -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[] {
+29 -2
View File
@@ -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<void>;
};
type AgentLike = {
@@ -4265,6 +4268,10 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
},
) as Record<string, unknown>;
const portablePermissions = pruneDefaultLikeValue(agent.permissions ?? {}, { dropFalseBooleans: true }) as Record<string, unknown>;
// 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 <T extends { id: string; role: string }>(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"));