From 1a0c7bee7596d780cbb4807a0496e265bd2d4a9b Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 1 Jul 2026 19:01:49 +0000 Subject: [PATCH] Enforce low-trust allowlist for user secrets Co-Authored-By: Paperclip --- server/src/__tests__/secrets-service.test.ts | 50 ++++++++++++++++++++ server/src/services/secrets.ts | 21 +++++++- 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 14dbf2775e6..99ef90558d6 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -346,6 +346,56 @@ describeEmbeddedPostgres("secretService", () => { expect(resolved.manifest[0]?.bindingId).toBe(binding!.id); }); + it("denies user secret resolution outside the low-trust declaration allowlist", async () => { + const companyId = await seedCompany(); + await seedCompanyMember(companyId, "user-1", "owner"); + const svc = secretService(db); + const definition = await svc.createUserSecretDefinition(companyId, { + key: "github_token", + name: "GitHub token", + provider: "local_encrypted", + }); + const env = { + GITHUB_TOKEN: { type: "user_secret_ref" as const, key: "github_token", version: "latest" as const }, + }; + + await svc.syncEnvBindingsForTarget(companyId, { targetType: "agent", targetId: "agent-1" }, env); + await svc.createCurrentUserSecretValue(companyId, "user-1", { + definitionKey: "github_token", + value: "user-one-secret", + }); + const [declaration] = await db + .select() + .from(userSecretDeclarations) + .where(eq(userSecretDeclarations.userSecretDefinitionId, definition.id)); + expect(declaration?.id).toBeTruthy(); + + await expect( + svc.resolveEnvBindings(companyId, env, { + consumerType: "agent", + consumerId: "agent-1", + actorType: "agent", + actorId: "agent-1", + responsibleUserId: "user-1", + allowedBindingIds: ["11111111-1111-4111-8111-111111111111"], + }), + ).rejects.toMatchObject({ + status: 422, + details: { code: "binding_not_allowed" }, + }); + + const resolved = await svc.resolveEnvBindings(companyId, env, { + consumerType: "agent", + consumerId: "agent-1", + actorType: "agent", + actorId: "agent-1", + responsibleUserId: "user-1", + allowedBindingIds: [declaration!.id], + }); + expect(resolved.env.GITHUB_TOKEN).toBe("user-one-secret"); + expect(resolved.manifest[0]?.bindingId).toBe(declaration!.id); + }); + it("resolves routine env secret refs through routine bindings and records value-free access metadata", async () => { const companyId = await seedCompany(); const svc = secretService(db); diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 99c4a12dae0..b9c384f4545 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -2262,8 +2262,9 @@ export function secretService(db: Db) { code: "responsible_user_missing", }); } + let declaration: typeof userSecretDeclarations.$inferSelect | null = null; if (context?.configPath) { - const declaration = await db + declaration = await db .select() .from(userSecretDeclarations) .where(and( @@ -2281,6 +2282,15 @@ export function secretService(db: Db) { ); } } + if ( + Array.isArray(context?.allowedBindingIds) && + (!declaration || !context.allowedBindingIds.includes(declaration.id)) + ) { + throw unprocessable( + "User secret declaration is outside the active low-trust boundary", + { code: "binding_not_allowed" }, + ); + } const secret = await getUserSecretValue({ companyId, ownerUserId: responsibleUserId, @@ -2294,7 +2304,7 @@ export function secretService(db: Db) { responsibleUserId, }); } - return await resolveSecretValueInternal( + const resolution = await resolveSecretValueInternal( companyId, secret.id, input.version ?? "latest", @@ -2303,6 +2313,13 @@ export function secretService(db: Db) { allowUserSecretScope: true, }, ); + return { + ...resolution, + manifestEntry: { + ...resolution.manifestEntry, + bindingId: declaration?.id ?? resolution.manifestEntry.bindingId ?? null, + }, + }; }, previewRemoteImport: async (