From 54dd0f4868f86cb234f0d6dff7cc10f6e9722fd2 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Thu, 3 Sep 2026 23:26:51 -0700 Subject: [PATCH] feat(agents): grant new agents hire permission by default (#12814) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- doc/CLI.md | 6 +- packages/shared/src/validators/agent.ts | 4 +- .../agent-permissions-service.test.ts | 100 +++++++++++++++--- .../__tests__/authorization-service.test.ts | 65 ++++++++++++ server/src/services/agent-permissions.ts | 56 ++++++++-- server/src/services/agents.ts | 16 ++- server/src/services/authorization.ts | 30 ++++-- ui/src/lib/trust-policy-ui.test.ts | 14 +++ ui/src/lib/trust-policy-ui.ts | 4 + 9 files changed, 251 insertions(+), 44 deletions(-) diff --git a/doc/CLI.md b/doc/CLI.md index 971648137e..0cdd67107b 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -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 ` to attach it to a specific issue. During Paperclip diff --git a/packages/shared/src/validators/agent.ts b/packages/shared/src/validators/agent.ts index 2be813ca6f..4a9416d14a 100644 --- a/packages/shared/src/validators/agent.ts +++ b/packages/shared/src/validators/agent.ts @@ -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(), diff --git a/server/src/__tests__/agent-permissions-service.test.ts b/server/src/__tests__/agent-permissions-service.test.ts index ddd7dc9b22..6f8e37e300 100644 --- a/server/src/__tests__/agent-permissions-service.test.ts +++ b/server/src/__tests__/agent-permissions-service.test.ts @@ -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", () => { diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index 871aabb27e..7a303268f4 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -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"); diff --git a/server/src/services/agent-permissions.ts b/server/src/services/agent-permissions.ts index 49eba9aa4e..e628a62ec5 100644 --- a/server/src/services/agent-permissions.ts +++ b/server/src/services/agent-permissions.ts @@ -1,28 +1,68 @@ +import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared"; + export type NormalizedAgentPermissions = Record & { canCreateAgents: boolean; canCreateSkills: boolean; }; -export function defaultPermissionsForRole(role: string): NormalizedAgentPermissions { +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : 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; - const preserved = { ...record }; return { - ...preserved, + ...record, canCreateAgents: typeof record.canCreateAgents === "boolean" ? record.canCreateAgents diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 86dee4a2a2..f785397746 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -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; 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)) diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index 6a20e38a3c..c6cad46937 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -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).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", diff --git a/ui/src/lib/trust-policy-ui.test.ts b/ui/src/lib/trust-policy-ui.test.ts index c142c6bb6f..a791c62a15 100644 --- a/ui/src/lib/trust-policy-ui.test.ts +++ b/ui/src/lib/trust-policy-ui.test.ts @@ -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", diff --git a/ui/src/lib/trust-policy-ui.ts b/ui/src/lib/trust-policy-ui.ts index f2442435d8..86ee6d5f47 100644 --- a/ui/src/lib/trust-policy-ui.ts +++ b/ui/src/lib/trust-policy-ui.ts @@ -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), };