mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
Constrain low-trust secret fallback scope
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user