From 1cbb6f49e89f6ee622cc9939959954b78f6e37fb Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 19 Aug 2026 15:20:10 +0000 Subject: [PATCH] Constrain low-trust secret fallback scope Co-Authored-By: Paperclip --- .../__tests__/heartbeat-project-env.test.ts | 72 ++++++++++++++++++- server/src/__tests__/secrets-service.test.ts | 14 +++- server/src/services/heartbeat.ts | 34 +++++++-- server/src/services/secrets.ts | 14 +++- 4 files changed, 123 insertions(+), 11 deletions(-) diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index d3a28be748c..b4f621d6c03 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -730,7 +730,12 @@ describe("resolveExecutionRunAdapterConfig", () => { resolveEnvBindings: vi.fn(), resolveInjectedEnvBindingForRuntime, getByNameInsensitive: vi.fn().mockResolvedValue({ id: "secret-1", name: "gh_token" }), - listBindings: vi.fn().mockResolvedValue([{ id: "different-binding" }]), + listBindings: vi.fn().mockResolvedValue([{ + id: "allowed-binding", + targetType: "project", + targetId: "other-project", + configPath: "env.GH_TOKEN", + }]), } as any, })).rejects.toMatchObject({ code: "configuration_incomplete", @@ -743,6 +748,71 @@ describe("resolveExecutionRunAdapterConfig", () => { expect(resolveInjectedEnvBindingForRuntime).not.toHaveBeenCalled(); }); + it("injects a low-trust fallback only through an allowlisted binding for the active target and env path", async () => { + const resolveInjectedEnvBindingForRuntime = vi.fn().mockResolvedValue({ + env: { GH_TOKEN: "resolved-token" }, + secretKeys: new Set(["GH_TOKEN"]), + manifest: [{ secretId: "secret-1", bindingId: "allowed-binding", outcome: "success" }], + }); + await resolveExecutionRunAdapterConfig({ + companyId: "company-1", + agentId: "agent-1", + issueId: "issue-1", + heartbeatRunId: "run-1", + executionRunConfig: { env: {} }, + projectEnv: null, + trustPreset: { + kind: "low_trust_review", + preset: LOW_TRUST_REVIEW_PRESET, + boundary: { + mode: LOW_TRUST_REVIEW_PRESET, + companyId: "company-1", + issueIds: ["issue-1"], + allowedSecretBindingIds: ["allowed-binding"], + }, + sourcePresets: {}, + }, + requiredScopedEnvBinding: { + keys: ["GH_TOKEN", "GITHUB_TOKEN"], + consumerScopes: ["agent", "project", "environment", "routine"], + reason: "push_write_credential_missing", + remediation: "No GitHub credential found.", + fallbackSecretNames: ["GITHUB_TOKEN", "GH_TOKEN", "PAPERCLIP_GITHUB_TOKEN"], + fallbackInjectionEnvKey: "GH_TOKEN", + }, + secretsSvc: { + resolveAdapterConfigForRuntime: vi.fn().mockResolvedValue({ + config: { env: {} }, + secretKeys: new Set(), + manifest: [], + }), + resolveEnvBindings: vi.fn(), + resolveInjectedEnvBindingForRuntime, + getByNameInsensitive: vi.fn().mockResolvedValue({ id: "secret-1", name: "gh_token" }), + listBindings: vi.fn().mockResolvedValue([{ + id: "allowed-binding", + targetType: "agent", + targetId: "agent-1", + configPath: "env.GH_TOKEN", + }]), + collectMissingRuntimeBindings: vi.fn().mockResolvedValue([]), + } as any, + }); + + expect(resolveInjectedEnvBindingForRuntime).toHaveBeenCalledWith( + "company-1", + "GH_TOKEN", + expect.objectContaining({ type: "secret_ref", secretId: "secret-1" }), + expect.objectContaining({ consumerType: "run", consumerId: "run-1" }), + { + bindingId: "allowed-binding", + targetType: "agent", + targetId: "agent-1", + configPath: "env.GH_TOKEN", + }, + ); + }); + it("skips well-known-secret injection when a supported-scope binding exists", async () => { const getByNameInsensitive = vi.fn(); const resolveInjectedEnvBindingForRuntime = vi.fn(); diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 78e34b46f7f..12e123828f1 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -251,7 +251,12 @@ describeEmbeddedPostgres("secretService", () => { "GH_TOKEN", { type: "secret_ref", secretId: secret.id, version: "latest" }, context, - randomUUID(), + { + bindingId: randomUUID(), + targetType: "agent", + targetId: "agent-1", + configPath: "access.GITHUB", + }, )).rejects.toMatchObject({ status: 422, details: { code: "binding_not_allowed" }, @@ -262,7 +267,12 @@ describeEmbeddedPostgres("secretService", () => { "GH_TOKEN", { type: "secret_ref", secretId: secret.id, version: "latest" }, context, - binding.id, + { + bindingId: binding.id, + targetType: "agent", + targetId: "agent-1", + configPath: "access.GITHUB", + }, ); expect(resolved.manifest[0]).toMatchObject({ configPath: "env.GH_TOKEN", diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 1dd92ff8b61..7d9c9798891 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -955,7 +955,12 @@ export async function resolveExecutionRunAdapterConfig(input: { )) : false; let fallbackSecret: Awaited> = null; - let fallbackAuthorizationBindingId: string | null = null; + let fallbackAuthorization: { + bindingId: string; + targetType: "agent" | "project" | "environment" | "routine"; + targetId: string; + configPath: string; + } | null = null; if ( requiredScopedEnvBinding && !requiredScopedBindingsConfigured @@ -967,9 +972,28 @@ export async function resolveExecutionRunAdapterConfig(input: { if (!candidate) continue; if (lowTrustAllowedBindingIds !== undefined) { const bindings = await input.secretsSvc.listBindings(input.companyId, candidate.id); - const bindingIds = new Set(bindings.map((binding) => binding.id)); - fallbackAuthorizationBindingId = lowTrustAllowedBindingIds.find((id) => bindingIds.has(id)) ?? null; - if (!fallbackAuthorizationBindingId) continue; + const activeTargetIds = new Map([ + ["agent", input.agentId], + ["project", input.projectId], + ["environment", input.environmentId], + ["routine", input.routineId], + ]); + const allowedConfigPaths = new Set(requiredScopedEnvBinding.keys.map((key) => `env.${key}`)); + const bindingsById = new Map(bindings.map((binding) => [binding.id, binding])); + const authorizedBinding = lowTrustAllowedBindingIds + .map((id) => bindingsById.get(id)) + .find((binding) => + binding + && activeTargetIds.get(binding.targetType) === binding.targetId + && allowedConfigPaths.has(binding.configPath), + ); + if (!authorizedBinding) continue; + fallbackAuthorization = { + bindingId: authorizedBinding.id, + targetType: authorizedBinding.targetType as "agent" | "project" | "environment" | "routine", + targetId: authorizedBinding.targetId, + configPath: authorizedBinding.configPath, + }; } fallbackSecret = candidate; break; @@ -1220,7 +1244,7 @@ export async function resolveExecutionRunAdapterConfig(input: { issueId: input.issueId ?? null, heartbeatRunId: input.heartbeatRunId ?? null, }, - fallbackAuthorizationBindingId, + fallbackAuthorization, ) : { env: {}, secretKeys: new Set(), manifest: [] }; if (Object.keys(injectedEnvResolution.env).length > 0) { diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index a5c73c58de1..2471ff2c32e 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -4925,7 +4925,12 @@ export function secretService(db: Db) { envKey: string, rawBinding: unknown, context: Omit, - authorizedBindingId?: string | null, + authorization?: { + bindingId: string; + targetType: SecretBindingTargetType; + targetId: string; + configPath: string; + } | null, ): Promise<{ env: Record; secretKeys: Set; manifest: RuntimeSecretManifestEntry[] }> => { if (!ENV_KEY_RE.test(envKey)) { throw unprocessable(`Invalid environment variable name: ${envKey}`); @@ -4940,14 +4945,17 @@ export function secretService(db: Db) { } let bindingContext: SecretBindingContext | undefined; - if (authorizedBindingId) { + if (authorization) { const authorizationBinding = await db .select() .from(companySecretBindings) .where(and( - eq(companySecretBindings.id, authorizedBindingId), + eq(companySecretBindings.id, authorization.bindingId), eq(companySecretBindings.companyId, companyId), eq(companySecretBindings.secretId, binding.secretId), + eq(companySecretBindings.targetType, authorization.targetType), + eq(companySecretBindings.targetId, authorization.targetId), + eq(companySecretBindings.configPath, authorization.configPath), )) .then((rows) => rows[0] ?? null); if (!authorizationBinding) {