Harden low-trust config authorization

This commit is contained in:
Dotta committed 2026-07-09 18:52:54 +00:00
1 parent eb4a49a6b6
commit 9ec1a1b41e
5 files changed
+107 -2

No files matched your search

+4
View File
@@ -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
@@ -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" });
@@ -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);
-2
View File
@@ -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",
+3
View File
@@ -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"
) {