diff --git a/doc/LOW-TRUST-PRESETS.md b/doc/LOW-TRUST-PRESETS.md index 7a876c94b03..476af516cba 100644 --- a/doc/LOW-TRUST-PRESETS.md +++ b/doc/LOW-TRUST-PRESETS.md @@ -37,6 +37,10 @@ changes that behavior. Low-trust containment instead limits what the low-trust agent can read or mutate through the Paperclip API and prevents raw untrusted output from being automatically promoted into higher-trust agent context. +Low-trust agents cannot read or mutate agent configuration, instruction bundles, +or company skill configuration through direct grants. Configuration changes from +low-trust work must go through higher-trust review and promotion paths instead. + ## Runtime Containment Managed `low_trust_review` runs fail closed unless Paperclip can enforce the diff --git a/server/src/__tests__/authorization-service.test.ts b/server/src/__tests__/authorization-service.test.ts index 2751f2fe694..c03a877b92a 100644 --- a/server/src/__tests__/authorization-service.test.ts +++ b/server/src/__tests__/authorization-service.test.ts @@ -717,6 +717,69 @@ describeEmbeddedPostgres("authorization service", () => { })).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" }); }); + it("blocks low-trust configuration actions before evaluating explicit change grants", async () => { + const company = await createCompany(db, "LowTrustConfigGrants"); + const project = await createProject(db, company.id, "Allowed"); + const targetAgent = await createAgent(db, company.id); + const actorAgent = await createAgent(db, company.id, { + role: "ceo", + permissions: { + trustPreset: LOW_TRUST_REVIEW_PRESET, + authorizationPolicy: { + trustBoundary: { + mode: LOW_TRUST_REVIEW_PRESET, + companyId: company.id, + projectIds: [project.id], + allowedAgentIds: [targetAgent.id], + }, + }, + }, + }); + await grantAgentPermission(db, company.id, actorAgent.id, "agents:configure"); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "agent", + principalId: actorAgent.id, + permissionKey: "skills:create", + grantedByUserId: null, + }); + + const authz = authorizationService(db); + const actor = { type: "agent" as const, agentId: actorAgent.id, companyId: company.id, source: "agent_key" as const }; + + await expect(authz.decide({ + actor, + action: "agent:read", + resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, + })).resolves.toMatchObject({ allowed: true, reason: "allow_low_trust_boundary" }); + + await expect(authz.decide({ + actor, + action: "agent_config:read", + resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, + })).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" }); + + await expect(authz.decide({ + actor, + action: "agent_config:read", + resource: { type: "agent", companyId: company.id, agentId: actorAgent.id }, + })).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" }); + + await expect(authz.decide({ + actor, + action: "agent_config:update", + resource: { type: "agent", companyId: company.id, agentId: targetAgent.id }, + scope: { requiresChangeGrant: true, consentedChange: true }, + })).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" }); + + await expect(authz.decide({ + actor, + action: "skill_config:update", + resource: { type: "company", companyId: company.id }, + scope: { consentedChange: true }, + })).resolves.toMatchObject({ allowed: false, reason: "deny_low_trust_boundary" }); + }); + it("denies simple-mode assignment when the target agent requires protected-assignment approval", async () => { const company = await createCompany(db, "ProtectedAssignment"); const actorAgent = await createAgent(db, company.id, { role: "engineer" }); diff --git a/server/src/__tests__/low-trust-red-team-routes.test.ts b/server/src/__tests__/low-trust-red-team-routes.test.ts index 3665e7819cb..f21d50edbff 100644 --- a/server/src/__tests__/low-trust-red-team-routes.test.ts +++ b/server/src/__tests__/low-trust-red-team-routes.test.ts @@ -13,6 +13,7 @@ import { approvals, assets, companies, + companyMemberships, companySkills, createDb, documentAnnotationComments, @@ -31,6 +32,7 @@ import { issues, issueThreadInteractions, issueWorkProducts, + principalPermissionGrants, projects, } from "@paperclipai/db"; import { ISSUE_CONTINUATION_SUMMARY_DOCUMENT_KEY, LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared"; @@ -653,6 +655,8 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => await deleteHeartbeatRunsAndWakeupsAfterActivityLogDrains(db); await db.delete(issues); await db.delete(agentRuntimeState); + await db.delete(principalPermissionGrants); + await db.delete(companyMemberships); await db.delete(agents); await db.delete(projects); await db.delete(companySkills); @@ -782,6 +786,29 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => it("restricts low-trust self inspection without changing standard-agent visibility", async () => { const fixture = await seedLowTrustFixture(db); + await db.insert(companyMemberships).values({ + companyId: fixture.company.id, + principalType: "agent", + principalId: fixture.agents.lowTrust.id, + status: "active", + membershipRole: "member", + }); + await db.insert(principalPermissionGrants).values([ + { + companyId: fixture.company.id, + principalType: "agent", + principalId: fixture.agents.lowTrust.id, + permissionKey: "agents:configure", + grantedByUserId: null, + }, + { + companyId: fixture.company.id, + principalType: "agent", + principalId: fixture.agents.lowTrust.id, + permissionKey: "skills:create", + grantedByUserId: null, + }, + ]); const lowTrustRes = await request(createApp(db, agentActor(fixture))).get("/api/agents/me"); expect(lowTrustRes.status, JSON.stringify(lowTrustRes.body)).toBe(200); @@ -810,6 +837,16 @@ describeEmbeddedPostgres("low-trust red-team HTTP route regression suite", () => expect(lowTrustSelfByIdRes.body).not.toHaveProperty("access"); expectNoCanary(lowTrustSelfByIdRes.body, fixture.canaries.agentConfig); + const lowTrustPeerConfigRes = await request(createApp(db, agentActor(fixture))) + .get(`/api/agents/${fixture.agents.collaborator.id}/configuration`); + expect(lowTrustPeerConfigRes.status, JSON.stringify(lowTrustPeerConfigRes.body)).toBe(403); + expectNoCanary(lowTrustPeerConfigRes.body, fixture.canaries.agentConfig); + + const lowTrustSelfBundleRes = await request(createApp(db, agentActor(fixture))) + .get(`/api/agents/${fixture.agents.lowTrust.id}/instructions-bundle`); + expect(lowTrustSelfBundleRes.status, JSON.stringify(lowTrustSelfBundleRes.body)).toBe(403); + expectNoCanary(lowTrustSelfBundleRes.body, fixture.canaries.agentConfig); + const standardActor = agentActor(fixture, fixture.agents.standard.id); const standardRes = await request(createApp(db, { ...standardActor, runId: null })).get("/api/agents/me"); expect(standardRes.status, JSON.stringify(standardRes.body)).toBe(200); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 8f020d8218b..2d7c5a3e701 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -839,8 +839,6 @@ export function agentRoutes( if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) { throw forbidden("Agent key cannot access another company"); } - if (actorAgent.id === targetAgent.id) return; - const decision = await access.decide({ actor: req.actor, action: "agent_config:read", diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index d52f855a12f..472a2d65a84 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -889,6 +889,9 @@ export function authorizationService(db: Db) { if ( input.action === "company_scope:read" || + input.action === "agent_config:read" || + input.action === "agent_config:update" || + input.action === "skill_config:update" || input.action === "runtime:manage" || input.action === "secrets:read" ) {