Constrain low-trust secret fallback scope

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-19 15:20:10 +00:00
1 parent 7cb5536459
commit 1cbb6f49e8
4 files changed
+123 -11

No files matched your search

@@ -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<string>(),
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();
+12 -2
View File
@@ -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",
+29 -5
View File
@@ -955,7 +955,12 @@ export async function resolveExecutionRunAdapterConfig(input: {
))
: false;
let fallbackSecret: Awaited<ReturnType<RuntimeConfigSecretResolver["getByNameInsensitive"]>> = 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<string, string | null | undefined>([
["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<string>(), manifest: [] };
if (Object.keys(injectedEnvResolution.env).length > 0) {
+11 -3
View File
@@ -4925,7 +4925,12 @@ export function secretService(db: Db) {
envKey: string,
rawBinding: unknown,
context: Omit<SecretConsumerContext, "configPath" | "allowedBindingIds">,
authorizedBindingId?: string | null,
authorization?: {
bindingId: string;
targetType: SecretBindingTargetType;
targetId: string;
configPath: string;
} | null,
): Promise<{ env: Record<string, string>; secretKeys: Set<string>; 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) {