From e590bca4811cac0672538c1c2b7763ca3a750f59 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 19 Aug 2026 16:45:55 +0000 Subject: [PATCH] Fail closed on empty GitHub token bindings Co-Authored-By: Paperclip --- .../__tests__/heartbeat-project-env.test.ts | 49 +++++++++++++++++++ server/src/services/heartbeat.ts | 31 +++++++++--- 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/server/src/__tests__/heartbeat-project-env.test.ts b/server/src/__tests__/heartbeat-project-env.test.ts index 03cd7d52ffe..e2d798d8bda 100644 --- a/server/src/__tests__/heartbeat-project-env.test.ts +++ b/server/src/__tests__/heartbeat-project-env.test.ts @@ -864,6 +864,55 @@ describe("resolveExecutionRunAdapterConfig", () => { expect(resolveInjectedEnvBindingForRuntime).not.toHaveBeenCalled(); }); + it.each([ + ["agent", "empty string", { executionRunConfig: { env: { GH_TOKEN: "" } } }], + ["agent", "empty plain binding", { executionRunConfig: { env: { GITHUB_TOKEN: { type: "plain", value: "" } } } }], + ["project", "empty string", { projectEnv: { GH_TOKEN: "" } }], + ["project", "empty plain binding", { projectEnv: { GITHUB_TOKEN: { type: "plain", value: "" } } }], + ["environment", "empty string", { environmentEnv: { GH_TOKEN: "" } }], + ["environment", "empty plain binding", { environmentEnv: { GITHUB_TOKEN: { type: "plain", value: "" } } }], + ["routine", "empty string", { routineEnv: { GH_TOKEN: "" } }], + ["routine", "empty plain binding", { routineEnv: { GITHUB_TOKEN: { type: "plain", value: "" } } }], + ])("fails closed for an explicit %s-scoped %s instead of injecting a company secret", async (_scope, _binding, scopeConfig) => { + const getByNameInsensitive = vi.fn().mockResolvedValue({ id: "secret-1", name: "GH_TOKEN" }); + const resolveInjectedEnvBindingForRuntime = vi.fn(); + + await expect(resolveExecutionRunAdapterConfig({ + companyId: "company-1", + agentId: "agent-1", + issueId: "issue-1", + projectId: "project-1", + environmentId: "environment-1", + routineId: "routine-1", + executionRunConfig: { env: {} }, + projectEnv: null, + ...scopeConfig, + 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(), + resolveEnvBindings: vi.fn(), + resolveInjectedEnvBindingForRuntime, + getByNameInsensitive, + } as any, + })).rejects.toMatchObject({ + code: "configuration_incomplete", + resultJson: { + configurationIncomplete: { + reason: "push_write_credential_missing", + }, + }, + }); + expect(getByNameInsensitive).not.toHaveBeenCalled(); + expect(resolveInjectedEnvBindingForRuntime).not.toHaveBeenCalled(); + }); + 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/services/heartbeat.ts b/server/src/services/heartbeat.ts index 3d0b762faa4..be3b8485edf 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -823,7 +823,11 @@ function formatMissingBindingForOperator(missing: MissingRuntimeBinding): string return `secret ${secretLabel} not bound at ${missing.consumerType} ${missing.configPath}`; } -function isConfiguredEnvBindingValue(binding: unknown) { +function hasOwnEnvBinding(env: Record | null, key: string) { + return env !== null && Object.prototype.hasOwnProperty.call(env, key); +} + +function isUsableEnvBindingValue(binding: unknown) { const parsed = envBindingSchema.safeParse(binding); if (!parsed.success) return false; const value = parsed.data; @@ -939,19 +943,34 @@ export async function resolveExecutionRunAdapterConfig(input: { assertLowTrustEnvConfigAllowed(routineEnv, "routine.env"); } const requiredScopedEnvBinding = input.requiredScopedEnvBinding ?? null; + const requiredScopedBindingPresent = requiredScopedEnvBinding + ? requiredScopedEnvBinding.keys.some((key) => ( + requiredScopedEnvBinding.consumerScopes.includes("agent") + && hasOwnEnvBinding(agentEnv, key) + ) || ( + requiredScopedEnvBinding.consumerScopes.includes("project") + && hasOwnEnvBinding(projectEnv, key) + ) || ( + requiredScopedEnvBinding.consumerScopes.includes("environment") + && hasOwnEnvBinding(environmentEnv, key) + ) || ( + requiredScopedEnvBinding.consumerScopes.includes("routine") + && hasOwnEnvBinding(routineEnv, key) + )) + : false; const requiredScopedBindingsConfigured = requiredScopedEnvBinding ? requiredScopedEnvBinding.keys.some((key) => ( requiredScopedEnvBinding.consumerScopes.includes("agent") - && isConfiguredEnvBindingValue(agentEnv[key]) + && isUsableEnvBindingValue(agentEnv[key]) ) || ( requiredScopedEnvBinding.consumerScopes.includes("project") - && isConfiguredEnvBindingValue(projectEnv?.[key]) + && isUsableEnvBindingValue(projectEnv?.[key]) ) || ( requiredScopedEnvBinding.consumerScopes.includes("environment") - && isConfiguredEnvBindingValue(environmentEnv?.[key]) + && isUsableEnvBindingValue(environmentEnv?.[key]) ) || ( requiredScopedEnvBinding.consumerScopes.includes("routine") - && isConfiguredEnvBindingValue(routineEnv?.[key]) + && isUsableEnvBindingValue(routineEnv?.[key]) )) : false; let fallbackSecret: Awaited> | null = null; @@ -963,7 +982,7 @@ export async function resolveExecutionRunAdapterConfig(input: { } | null = null; if ( requiredScopedEnvBinding - && !requiredScopedBindingsConfigured + && !requiredScopedBindingPresent && requiredScopedEnvBinding.fallbackSecretNames?.length && typeof input.secretsSvc.getByNameInsensitive === "function" ) {