feat(agents): grant new agents hire permission by default (#12814)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Agent permissions control which agents can create or hire other
agents (`canCreateAgents`)
> - Today only CEO-role agents get this permission by default; every
other agent starts without it
> - Teams that want agents to delegate and build out their own teams
must flip the toggle on each hire, and most operators want delegation to
work out of the box
> - This pull request makes `canCreateAgents` default to enabled for new
standard-trust agents, while low-trust agents keep a disabled default
> - The benefit is that agent teams can grow without per-agent
permission toggling, while low-trust containment and checkout protection
stay intact

## Linked Issues or Issue Description

Related (not fixed by this PR): #8064 also decouples an authority from
`agents:create`.

**Subsystem affected**

Server agent permissions (`server/src/services/agent-permissions.ts`),
authorization (`server/src/services/authorization.ts`), the shared
`agentPermissionsSchema` validator, and the UI trust-preset helper.

**Problem or motivation**

New agents cannot hire other agents unless an operator enables
`canCreateAgents` on each one. Only CEO-role agents get the permission
by default. This blocks delegation-by-default workflows. Operators must
toggle the permission for every hire.

**Proposed solution**

Default `canCreateAgents` to `true` for newly created agents. Apply and
persist the default at creation only. Stored rows without an explicit
value stay fail-closed at read and enforcement time. Keep the default at
`false` when the agent's permissions record marks it low-trust (the
`low_trust_review` preset or a trust boundary). Explicit values always
win. Decouple `tasks:manage_active_checkouts` from `canCreateAgents` so
the default-on flag does not let a peer agent write over another agent's
checked-out issue.

**Alternatives considered**

Granting the default only at the route layer would leave stored rows and
enforcement out of sync. Keeping the checkout authority coupled to
`canCreateAgents` would void the active-checkout write protection once
the flag is default-on. A per-company setting adds configuration surface
without a clear need; explicit per-agent overrides already exist.

**Roadmap alignment**

Governance and trust-preset work already separates standard-trust from
low-trust agents. This change follows that line: capability by default
for standard trust, containment by default for low trust.

## What Changed

- `normalizeAgentPermissions` now takes a `create`/`stored` context.
Creation writes get the new default: enabled unless
`permissionsImplyLowTrust()` detects the low-trust review preset or a
trust boundary. Stored rows without an explicit value normalize to
disabled (fail-closed). The role parameter is gone.
- `agentPermissionsSchema` no longer injects `canCreateAgents: false`
when the field is omitted. The server-side default applies instead.
- `authorization.ts` normalizes raw agent rows for `agents:create`, so
enforcement matches what the API reports for legacy rows.
- `tasks:manage_active_checkouts` no longer rides on `canCreateAgents`.
CEO role, explicit grants, and the manager chain remain the paths.
- `agents:create` is denied outright inside any resolved low-trust
execution context (agent, project, issue, or run policy). The default-on
flag can never reach the legacy creator allow there.
- The UI trust-preset helper sets `canCreateAgents: false` when an agent
is switched to the low-trust preset, instead of carrying the old value
forward.
- `doc/CLI.md` describes the new default for `teams install`.
- Tests pin the default matrix (standard, low-trust, explicit overrides)
on the server and in the UI helper.

## Verification

- `cd server && npx vitest run
src/__tests__/agent-permissions-service.test.ts
src/__tests__/agent-permissions-routes.test.ts
src/__tests__/low-trust-red-team-routes.test.ts
src/__tests__/authorization-service.test.ts` — 143 tests pass.
- Broader sweep: 18 suites that touch `canCreateAgents` (hire,
pending-approval, teams catalog, portability, built-in agents,
plugin-managed agents) pass locally.
- `cd ui && npx vitest run src/lib/trust-policy-ui.test.ts
src/components/TrustPresetSection.test.tsx src/pages/NewAgent.test.tsx
src/pages/Agents.test.tsx` — passes.
- Typecheck is clean for the changed files in `packages/shared`,
`server`, and `ui`.

## Risks

- Behavioral shift: agents created after this change persist
`canCreateAgents: true` unless low-trust. Pre-existing agents keep their
stored value. Legacy or malformed permission records without an explicit
value stay fail-closed at read and enforcement time; they never gain the
authority retroactively.
- Low-trust runs can no longer create agents at all, even when the agent
carries an explicit `canCreateAgents: true`. Before this change, that
combination could hire. The red-team suite and a new authorization test
pin the denial.
- Narrowing: a non-CEO agent with `canCreateAgents: true` loses implicit
`tasks:manage_active_checkouts`. The manager chain and explicit grants
still provide it. This narrowing is deliberate; without it, the
default-on flag would let any peer bypass active-checkout write
protection.
- No migrations. No API shape changes. Low-trust defaults are covered by
the red-team regression suite.

## Model Used

- Claude Fable 5 (`claude-fable-5`), Anthropic — via Claude Code CLI
with extended thinking and tool use (code search, editing, local test
execution).

## 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
This commit is contained in:
Devin Foley 2026-09-03 23:26:51 -07:00 committed by GitHub
parent c38b59484c
commit 54dd0f4868
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 251 additions and 44 deletions

View File

@ -716,8 +716,10 @@ Preview/install options:
`paperclipai company current --json`, or `PAPERCLIP_COMPANY_ID` to select the
target company. `company list` falls back to the scoped current company when
board-wide listing is forbidden. `teams install` creates agents and therefore
requires board authentication, an `agents:create` grant, or an agent with
explicit `canCreateAgents` permission.
requires board authentication, an `agents:create` grant, or an agent with the
`canCreateAgents` permission (enabled by default for newly created
standard-trust agents; low-trust agents and pre-existing agents without an
explicit value stay disabled).
- `--request-approval-on-forbidden` turns a 403 install denial into a linked
board approval request instead of a raw failed command; use
`--approval-issue-id <id>` to attach it to a specific issue. During Paperclip

View File

@ -12,7 +12,9 @@ import { agentDesiredSkillSelectionSchema } from "./adapter-skills.js";
import { objectWithoutDefaults } from "./partial.js";
export const agentPermissionsSchema = z.object({
canCreateAgents: z.boolean().optional().default(false),
// No schema default: the server derives the default (enabled unless the
// permissions record marks the agent low-trust) when the field is omitted.
canCreateAgents: z.boolean().optional(),
canCreateSkills: z.boolean().optional().default(true),
trustPreset: trustPresetSchema.optional(),
authorizationPolicy: trustAuthorizationPolicySchema.optional(),

View File

@ -1,37 +1,103 @@
import { describe, expect, it } from "vitest";
import {
LOW_TRUST_REVIEW_PRESET,
agentPermissionsSchema,
updateAgentPermissionsSchema,
} from "@paperclipai/shared";
import {
defaultPermissionsForRole,
defaultAgentPermissions,
normalizeAgentPermissions,
permissionsImplyLowTrust,
} from "../services/agent-permissions.js";
describe("agent permissions service", () => {
it("keeps agent-creation authority least-privileged by default", () => {
expect(defaultPermissionsForRole("ceo").canCreateAgents).toBe(true);
expect(defaultPermissionsForRole("CTO").canCreateAgents).toBe(false);
expect(defaultPermissionsForRole("engineering-manager").canCreateAgents).toBe(false);
expect(defaultPermissionsForRole("engineer").canCreateAgents).toBe(false);
it("grants agent-creation authority to new agents by default", () => {
expect(defaultAgentPermissions({ context: "create" }).canCreateAgents).toBe(true);
expect(normalizeAgentPermissions(undefined, { context: "create" }).canCreateAgents).toBe(true);
expect(normalizeAgentPermissions({}, { context: "create" }).canCreateAgents).toBe(true);
expect(
normalizeAgentPermissions({ trustPreset: "standard" }, { context: "create" }).canCreateAgents,
).toBe(true);
});
it("enables skill creation for every role by default", () => {
expect(defaultPermissionsForRole("ceo").canCreateSkills).toBe(true);
expect(defaultPermissionsForRole("CTO").canCreateSkills).toBe(true);
expect(defaultPermissionsForRole("engineering-manager").canCreateSkills).toBe(true);
expect(defaultPermissionsForRole("engineer").canCreateSkills).toBe(true);
it("keeps stored rows without an explicit value fail-closed", () => {
expect(defaultAgentPermissions().canCreateAgents).toBe(false);
expect(defaultAgentPermissions({ context: "stored" }).canCreateAgents).toBe(false);
expect(normalizeAgentPermissions(undefined).canCreateAgents).toBe(false);
expect(normalizeAgentPermissions({}).canCreateAgents).toBe(false);
expect(normalizeAgentPermissions("malformed").canCreateAgents).toBe(false);
expect(normalizeAgentPermissions([]).canCreateAgents).toBe(false);
});
it("preserves explicit canCreateAgents overrides", () => {
expect(normalizeAgentPermissions({ canCreateAgents: false }, "cto").canCreateAgents).toBe(false);
expect(normalizeAgentPermissions({ canCreateAgents: true }, "engineer").canCreateAgents).toBe(true);
it("withholds agent-creation authority from new low-trust agents", () => {
expect(defaultAgentPermissions({ lowTrust: true, context: "create" }).canCreateAgents).toBe(false);
expect(
normalizeAgentPermissions(
{ trustPreset: LOW_TRUST_REVIEW_PRESET },
{ context: "create" },
).canCreateAgents,
).toBe(false);
expect(
normalizeAgentPermissions(
{ authorizationPolicy: { trustPreset: LOW_TRUST_REVIEW_PRESET } },
{ context: "create" },
).canCreateAgents,
).toBe(false);
expect(
normalizeAgentPermissions(
{ authorizationPolicy: { trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET } } },
{ context: "create" },
).canCreateAgents,
).toBe(false);
});
it("detects low-trust markers wherever the trust policy stores them", () => {
expect(permissionsImplyLowTrust(undefined)).toBe(false);
expect(permissionsImplyLowTrust({})).toBe(false);
expect(permissionsImplyLowTrust({ trustPreset: "standard" })).toBe(false);
expect(permissionsImplyLowTrust({ trustPreset: LOW_TRUST_REVIEW_PRESET })).toBe(true);
expect(permissionsImplyLowTrust({ reviewPreset: { id: LOW_TRUST_REVIEW_PRESET } })).toBe(true);
expect(
permissionsImplyLowTrust({ authorizationPolicy: { trustPreset: LOW_TRUST_REVIEW_PRESET } }),
).toBe(true);
expect(
permissionsImplyLowTrust({
authorizationPolicy: { reviewPreset: { id: LOW_TRUST_REVIEW_PRESET } },
}),
).toBe(true);
expect(
permissionsImplyLowTrust({
authorizationPolicy: { trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET } },
}),
).toBe(true);
});
it("enables skill creation by default", () => {
expect(defaultAgentPermissions().canCreateSkills).toBe(true);
expect(defaultAgentPermissions({ lowTrust: true, context: "create" }).canCreateSkills).toBe(true);
});
it("preserves explicit canCreateAgents overrides in both contexts", () => {
expect(normalizeAgentPermissions({ canCreateAgents: false }, { context: "create" }).canCreateAgents).toBe(false);
expect(normalizeAgentPermissions({ canCreateAgents: true }).canCreateAgents).toBe(true);
expect(
normalizeAgentPermissions({
canCreateAgents: true,
trustPreset: LOW_TRUST_REVIEW_PRESET,
}).canCreateAgents,
).toBe(true);
});
it("defaults missing skill creation permission to true and preserves explicit false", () => {
expect(normalizeAgentPermissions({}, "engineer").canCreateSkills).toBe(true);
expect(normalizeAgentPermissions({ canCreateSkills: false }, "ceo").canCreateSkills).toBe(false);
expect(normalizeAgentPermissions({ canCreateSkills: true }, "engineer").canCreateSkills).toBe(true);
expect(normalizeAgentPermissions({}).canCreateSkills).toBe(true);
expect(normalizeAgentPermissions({ canCreateSkills: false }).canCreateSkills).toBe(false);
expect(normalizeAgentPermissions({ canCreateSkills: true }).canCreateSkills).toBe(true);
});
it("leaves omitted canCreateAgents undefined at the schema layer", () => {
expect(agentPermissionsSchema.parse({}).canCreateAgents).toBeUndefined();
expect(agentPermissionsSchema.parse({ canCreateAgents: false }).canCreateAgents).toBe(false);
expect(agentPermissionsSchema.parse({ canCreateAgents: true }).canCreateAgents).toBe(true);
});
it("validates skill creation permission with a default-on value", () => {

View File

@ -1928,6 +1928,71 @@ describeEmbeddedPostgres("authorization service", () => {
});
});
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
// (canCreateAgents: true for standard trust); enforcement reads it back.
const actorAgent = await createAgent(db, company.id, {
role: "engineer",
permissions: { canCreateAgents: true, 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("keeps legacy rows without an explicit canCreateAgents fail-closed", async () => {
const company = await createCompany(db, "LegacyRowFailClosed");
const actorAgent = await createAgent(db, company.id, { role: "engineer", permissions: {} });
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",
});
});
it("denies agent creation for a low-trust boundary even with explicit canCreateAgents", async () => {
const company = await createCompany(db, "LowTrustHireDenied");
const project = await createProject(db, company.id, "Contained");
const actorAgent = await createAgent(db, company.id, {
permissions: {
canCreateAgents: true,
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: {
trustBoundary: {
mode: LOW_TRUST_REVIEW_PRESET,
projectIds: [project.id],
},
},
},
});
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_low_trust_boundary",
});
});
it("denies active-checkout management outside the CEO caller company scope", async () => {
const sourceCompany = await createCompany(db, "CheckoutSource");
const targetCompany = await createCompany(db, "CheckoutTarget");

View File

@ -1,28 +1,68 @@
import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared";
export type NormalizedAgentPermissions = Record<string, unknown> & {
canCreateAgents: boolean;
canCreateSkills: boolean;
};
export function defaultPermissionsForRole(role: string): NormalizedAgentPermissions {
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
/**
* Mirrors the agent-source low-trust markers consumed by
* resolveCoreTrustPreset: the low-trust review preset (top-level or inside
* authorizationPolicy) or a low-trust boundary. Defaults must never grant
* agent-creation authority to a low-trust agent.
*/
export function permissionsImplyLowTrust(permissions: unknown): boolean {
const record = asRecord(permissions);
if (!record) return false;
const authorizationPolicy = asRecord(record.authorizationPolicy);
return (
record.trustPreset === LOW_TRUST_REVIEW_PRESET ||
authorizationPolicy?.trustPreset === LOW_TRUST_REVIEW_PRESET ||
asRecord(record.reviewPreset)?.id === LOW_TRUST_REVIEW_PRESET ||
asRecord(authorizationPolicy?.reviewPreset)?.id === LOW_TRUST_REVIEW_PRESET ||
asRecord(authorizationPolicy?.trustBoundary) !== null
);
}
/**
* "create" is the context for permissions arriving on a new-agent write: the
* hire/create default applies and the resolved value is persisted. "stored"
* is the context for rows read back from the database: a row without an
* explicit value stays fail-closed, so the default is never granted
* retroactively to legacy or malformed records at read or enforcement time.
*/
export type AgentPermissionsContext = "create" | "stored";
export function defaultAgentPermissions(
options?: { lowTrust?: boolean; context?: AgentPermissionsContext },
): NormalizedAgentPermissions {
return {
canCreateAgents: role.trim().toLowerCase() === "ceo",
canCreateAgents: options?.context === "create" && options?.lowTrust !== true,
canCreateSkills: true,
};
}
export function normalizeAgentPermissions(
permissions: unknown,
role: string,
options?: { context?: AgentPermissionsContext },
): NormalizedAgentPermissions {
const defaults = defaultPermissionsForRole(role);
if (typeof permissions !== "object" || permissions === null || Array.isArray(permissions)) {
const defaults = defaultAgentPermissions({
lowTrust: permissionsImplyLowTrust(permissions),
context: options?.context ?? "stored",
});
const record = asRecord(permissions);
if (!record) {
return defaults;
}
const record = permissions as Record<string, unknown>;
const preserved = { ...record };
return {
...preserved,
...record,
canCreateAgents:
typeof record.canCreateAgents === "boolean"
? record.canCreateAgents

View File

@ -344,7 +344,7 @@ export function agentService(db: Db) {
function normalizeAgentBaseRow(row: typeof agents.$inferSelect) {
return withUrlKey({
...row,
permissions: normalizeAgentPermissions(row.permissions, row.role),
permissions: normalizeAgentPermissions(row.permissions),
});
}
@ -676,8 +676,7 @@ export function agentService(db: Db) {
const normalizedPatch = { ...data } as Partial<typeof agents.$inferInsert>;
if (data.permissions !== undefined) {
const role = (data.role ?? existing.role) as string;
normalizedPatch.permissions = normalizeAgentPermissions(data.permissions, role);
normalizedPatch.permissions = normalizeAgentPermissions(data.permissions);
}
if (
Object.prototype.hasOwnProperty.call(normalizedPatch, "adapterConfig") &&
@ -806,7 +805,7 @@ export function agentService(db: Db) {
const uniqueName = deduplicateAgentName(data.name, existingAgents);
const role = data.role ?? "general";
const normalizedPermissions = normalizeAgentPermissions(data.permissions, role);
const normalizedPermissions = normalizeAgentPermissions(data.permissions, { context: "create" });
const runtimeConfig = normalizeRuntimeConfigForNewAgent(data.runtimeConfig);
const adapterType = data.adapterType ?? "process";
const rawAdapterConfig = isPlainRecord(data.adapterConfig)
@ -1039,10 +1038,9 @@ export function agentService(db: Db) {
);
}
if (patch.permissions !== undefined) {
patch.permissions = normalizeAgentPermissions(
patch.permissions,
(patch.role ?? existing.role) as string,
);
// The pending-approval activation replays the original hire
// request, so the new-agent creation default applies.
patch.permissions = normalizeAgentPermissions(patch.permissions, { context: "create" });
}
const updated = await tx
.update(agents)
@ -1089,7 +1087,7 @@ export function agentService(db: Db) {
const updated = await db
.update(agents)
.set({
permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }, existing.role),
permissions: normalizeAgentPermissions({ ...existing.permissions, ...permissions }),
updatedAt: new Date(),
})
.where(eq(agents.id, id))

View File

@ -28,6 +28,7 @@ import {
type TrustPresetResolution,
} from "./trust-preset-resolver.js";
import { logger } from "../middleware/logger.js";
import { normalizeAgentPermissions } from "./agent-permissions.js";
import { grantsForHumanRole, normalizeHumanRole } from "./company-member-roles.js";
export type AuthorizationActor =
@ -170,8 +171,10 @@ function permissionForAction(action: AuthorizationAction): PermissionKey | null
function canCreateAgentsLegacy(agent: { role: string; permissions: unknown }) {
if (agent.role === "ceo") return true;
if (!agent.permissions || typeof agent.permissions !== "object") return false;
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
// 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;
}
function scopeValueList(value: unknown): string[] {
@ -984,6 +987,11 @@ export function authorizationService(db: Db | DbTransaction) {
if (
input.action === "company_scope:read" ||
// Agent creation is a company-wide privileged action. The default-on
// canCreateAgents flag must never reach the legacy creator allow when
// the effective execution context (agent, project, issue, or run
// policy) resolves to low trust.
input.action === "agents:create" ||
input.action === "decision_queue:manage" ||
input.action === "decision_queue:read" ||
input.action === "decision_triage:manage" ||
@ -2242,11 +2250,19 @@ export function authorizationService(db: Db | DbTransaction) {
if (grantDecision.allowed) return grantDecision;
}
if (
(input.action === "agents:create" ||
input.action === "tasks:manage_active_checkouts") &&
canCreateAgentsLegacy(actorAgent)
) {
if (input.action === "agents:create" && canCreateAgentsLegacy(actorAgent)) {
return allow({
action: input.action,
reason: "allow_legacy_agent_creator",
explanation: "Allowed by legacy agent creator authority.",
});
}
// Active-checkout management deliberately does not ride on
// canCreateAgents: that flag is default-on for standard-trust agents, and
// coupling would let any peer write over another agent's checked-out
// issue. CEOs, explicit grants, and the manager chain remain the paths.
if (input.action === "tasks:manage_active_checkouts" && actorAgent.role === "ceo") {
return allow({
action: input.action,
reason: "allow_legacy_agent_creator",

View File

@ -1,6 +1,7 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import {
buildPermissionsForTrustPreset,
clearSingleLowTrustBoundaryTarget,
getLowTrustBoundary,
getSingleLowTrustBoundaryTarget,
@ -10,6 +11,19 @@ import {
} from "./trust-policy-ui";
describe("trust-policy-ui low-trust boundary helpers", () => {
it("drops hire authority when switching to the low-trust preset", () => {
const demoted = buildPermissionsForTrustPreset(
{ canCreateAgents: true, canCreateSkills: true },
"low_trust_review",
);
expect(demoted.canCreateAgents).toBe(false);
expect(demoted.canCreateSkills).toBe(true);
const restored = buildPermissionsForTrustPreset(demoted, "standard");
expect(restored.canCreateAgents).toBe(false);
expect(restored.trustPreset).toBe("standard");
});
it("writes one project boundary with mode and company id", () => {
const permissions = setSingleLowTrustBoundaryTarget(null, "company-1", {
type: "project",

View File

@ -52,6 +52,10 @@ export function buildPermissionsForTrustPreset(
if (preset === LOW_TRUST_REVIEW_PRESET) {
return {
...current,
// Hire authority is default-on for standard-trust agents, so demoting
// to low-trust must drop it rather than carry the old value forward.
// Operators can re-enable it explicitly afterwards.
canCreateAgents: false,
trustPreset: LOW_TRUST_REVIEW_PRESET,
authorizationPolicy: buildLowTrustReviewPolicy(current.authorizationPolicy),
};