Implement secrets service with local encryption, redaction, and runtime resolution

Add AES-256-GCM local encrypted secrets provider with auto-generated
master key, stub providers for AWS/GCP/Vault, and a secrets service
that normalizes adapter configs (converting sensitive inline values to
secret refs in strict mode) and resolves secret refs back to plain
values at runtime. Extract redaction utilities from agent routes into
shared module. Redact sensitive values in activity logs, config
revisions, and approval payloads. Block rollback of revisions
containing redacted secrets. Filter hidden issues from list queries.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Forgotten
2026-02-19 15:43:52 -06:00
parent d26b67ebc3
commit 11901ae5d8
22 changed files with 1083 additions and 61 deletions

View File

@@ -1,6 +1,7 @@
import type { Db } from "@paperclip/db";
import { activityLog } from "@paperclip/db";
import { publishLiveEvent } from "./live-events.js";
import { sanitizeRecord } from "../redaction.js";
export interface LogActivityInput {
companyId: string;
@@ -15,6 +16,7 @@ export interface LogActivityInput {
}
export async function logActivity(db: Db, input: LogActivityInput) {
const sanitizedDetails = input.details ? sanitizeRecord(input.details) : null;
await db.insert(activityLog).values({
companyId: input.companyId,
actorType: input.actorType,
@@ -24,7 +26,7 @@ export async function logActivity(db: Db, input: LogActivityInput) {
entityId: input.entityId,
agentId: input.agentId ?? null,
runId: input.runId ?? null,
details: input.details ?? null,
details: sanitizedDetails,
});
publishLiveEvent({
@@ -38,7 +40,7 @@ export async function logActivity(db: Db, input: LogActivityInput) {
entityId: input.entityId,
agentId: input.agentId ?? null,
runId: input.runId ?? null,
details: input.details ?? null,
details: sanitizedDetails,
},
});
}

View File

@@ -13,6 +13,7 @@ import {
} from "@paperclip/db";
import { conflict, notFound, unprocessable } from "../errors.js";
import { normalizeAgentPermissions } from "./agent-permissions.js";
import { REDACTED_EVENT_VALUE, sanitizeRecord } from "../redaction.js";
function hashToken(token: string) {
return createHash("sha256").update(token).digest("hex");
@@ -60,6 +61,18 @@ function jsonEqual(left: unknown, right: unknown): boolean {
function buildConfigSnapshot(
row: Pick<typeof agents.$inferSelect, ConfigRevisionField>,
): AgentConfigSnapshot {
const adapterConfig =
typeof row.adapterConfig === "object" && row.adapterConfig !== null && !Array.isArray(row.adapterConfig)
? sanitizeRecord(row.adapterConfig as Record<string, unknown>)
: {};
const runtimeConfig =
typeof row.runtimeConfig === "object" && row.runtimeConfig !== null && !Array.isArray(row.runtimeConfig)
? sanitizeRecord(row.runtimeConfig as Record<string, unknown>)
: {};
const metadata =
typeof row.metadata === "object" && row.metadata !== null && !Array.isArray(row.metadata)
? sanitizeRecord(row.metadata as Record<string, unknown>)
: row.metadata ?? null;
return {
name: row.name,
role: row.role,
@@ -67,13 +80,20 @@ function buildConfigSnapshot(
reportsTo: row.reportsTo,
capabilities: row.capabilities,
adapterType: row.adapterType,
adapterConfig: row.adapterConfig ?? {},
runtimeConfig: row.runtimeConfig ?? {},
adapterConfig,
runtimeConfig,
budgetMonthlyCents: row.budgetMonthlyCents,
metadata: row.metadata ?? null,
metadata,
};
}
function containsRedactedMarker(value: unknown): boolean {
if (value === REDACTED_EVENT_VALUE) return true;
if (Array.isArray(value)) return value.some((item) => containsRedactedMarker(item));
if (typeof value !== "object" || value === null) return false;
return Object.values(value as Record<string, unknown>).some((entry) => containsRedactedMarker(entry));
}
function hasConfigPatchFields(data: Partial<typeof agents.$inferInsert>) {
return CONFIG_REVISION_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(data, field));
}
@@ -374,6 +394,9 @@ export function agentService(db: Db) {
.where(and(eq(agentConfigRevisions.agentId, id), eq(agentConfigRevisions.id, revisionId)))
.then((rows) => rows[0] ?? null);
if (!revision) return null;
if (containsRedactedMarker(revision.afterConfig)) {
throw unprocessable("Cannot roll back a revision that contains redacted secret values");
}
const patch = configPatchFromSnapshot(revision.afterConfig);
return updateAgent(id, patch, {

View File

@@ -17,6 +17,7 @@ import {
approvalComments,
approvals,
activityLog,
companySecrets,
} from "@paperclip/db";
export function companyService(db: Db) {
@@ -66,6 +67,7 @@ export function companyService(db: Db) {
await tx.delete(costEvents).where(eq(costEvents.companyId, id));
await tx.delete(approvalComments).where(eq(approvalComments.companyId, id));
await tx.delete(approvals).where(eq(approvals.companyId, id));
await tx.delete(companySecrets).where(eq(companySecrets.companyId, id));
await tx.delete(issues).where(eq(issues.companyId, id));
await tx.delete(goals).where(eq(goals.companyId, id));
await tx.delete(projects).where(eq(projects.companyId, id));

View File

@@ -17,6 +17,7 @@ import { getServerAdapter, runningProcesses } from "../adapters/index.js";
import type { AdapterExecutionResult, AdapterInvocationMeta, AdapterSessionCodec } from "../adapters/index.js";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { parseObject, asBoolean, asNumber, appendWithCap, MAX_EXCERPT_BYTES } from "../adapters/utils.js";
import { secretService } from "./secrets.js";
const MAX_LIVE_LOG_CHUNK_BYTES = 8 * 1024;
@@ -153,6 +154,7 @@ function resolveNextSessionState(input: {
export function heartbeatService(db: Db) {
const runLogStore = getRunLogStore();
const secretsSvc = secretService(db);
async function getAgent(agentId: string) {
return db
@@ -689,6 +691,10 @@ export function heartbeatService(db: Db) {
};
const config = parseObject(agent.adapterConfig);
const resolvedConfig = await secretsSvc.resolveAdapterConfigForRuntime(
agent.companyId,
config,
);
const onAdapterMeta = async (meta: AdapterInvocationMeta) => {
await appendRunEvent(currentRun, seq++, {
eventType: "adapter.invoke",
@@ -718,7 +724,7 @@ export function heartbeatService(db: Db) {
runId: run.id,
agent,
runtime: runtimeForAdapter,
config,
config: resolvedConfig,
context,
onLog,
onMeta: onAdapterMeta,

View File

@@ -6,6 +6,7 @@ export { issueApprovalService } from "./issue-approvals.js";
export { goalService } from "./goals.js";
export { activityService, type ActivityFilters } from "./activity.js";
export { approvalService } from "./approvals.js";
export { secretService } from "./secrets.js";
export { costService } from "./costs.js";
export { heartbeatService } from "./heartbeat.js";
export { dashboardService } from "./dashboard.js";

View File

@@ -2,6 +2,7 @@ import { and, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { approvals, issueApprovals, issues } from "@paperclip/db";
import { notFound, unprocessable } from "../errors.js";
import { redactEventPayload } from "../redaction.js";
interface LinkActor {
agentId?: string | null;
@@ -44,7 +45,7 @@ export function issueApprovalService(db: Db) {
const issue = await getIssue(issueId);
if (!issue) throw notFound("Issue not found");
return db
const result = await db
.select({
id: approvals.id,
companyId: approvals.companyId,
@@ -63,6 +64,10 @@ export function issueApprovalService(db: Db) {
.innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id))
.where(eq(issueApprovals.issueId, issueId))
.orderBy(desc(issueApprovals.createdAt));
return result.map((approval) => ({
...approval,
payload: redactEventPayload(approval.payload) ?? {},
}));
},
listIssuesForApproval: async (approvalId: string) => {

View File

@@ -71,6 +71,7 @@ export function issueService(db: Db) {
conditions.push(eq(issues.assigneeAgentId, filters.assigneeAgentId));
}
if (filters?.projectId) conditions.push(eq(issues.projectId, filters.projectId));
conditions.push(isNull(issues.hiddenAt));
const priorityOrder = sql`CASE ${issues.priority} WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END`;
return db.select().from(issues).where(and(...conditions)).orderBy(asc(priorityOrder), desc(issues.updatedAt));

View File

@@ -0,0 +1,364 @@
import { and, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclip/db";
import { companySecrets, companySecretVersions } from "@paperclip/db";
import type { AgentEnvConfig, EnvBinding, SecretProvider } from "@paperclip/shared";
import { envBindingSchema } from "@paperclip/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
import { getSecretProvider, listSecretProviders } from "../secrets/provider-registry.js";
const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const SENSITIVE_ENV_KEY_RE =
/(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i;
const REDACTED_SENTINEL = "***REDACTED***";
type CanonicalEnvBinding =
| { type: "plain"; value: string }
| { type: "secret_ref"; secretId: string; version: number | "latest" };
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function isSensitiveEnvKey(key: string) {
return SENSITIVE_ENV_KEY_RE.test(key);
}
function canonicalizeBinding(binding: EnvBinding): CanonicalEnvBinding {
if (typeof binding === "string") {
return { type: "plain", value: binding };
}
if (binding.type === "plain") {
return { type: "plain", value: String(binding.value) };
}
return {
type: "secret_ref",
secretId: binding.secretId,
version: binding.version ?? "latest",
};
}
export function secretService(db: Db) {
async function getById(id: string) {
return db
.select()
.from(companySecrets)
.where(eq(companySecrets.id, id))
.then((rows) => rows[0] ?? null);
}
async function getByName(companyId: string, name: string) {
return db
.select()
.from(companySecrets)
.where(and(eq(companySecrets.companyId, companyId), eq(companySecrets.name, name)))
.then((rows) => rows[0] ?? null);
}
async function getSecretVersion(secretId: string, version: number) {
return db
.select()
.from(companySecretVersions)
.where(
and(
eq(companySecretVersions.secretId, secretId),
eq(companySecretVersions.version, version),
),
)
.then((rows) => rows[0] ?? null);
}
async function assertSecretInCompany(companyId: string, secretId: string) {
const secret = await getById(secretId);
if (!secret) throw notFound("Secret not found");
if (secret.companyId !== companyId) throw unprocessable("Secret must belong to same company");
return secret;
}
async function resolveSecretValue(
companyId: string,
secretId: string,
version: number | "latest",
): Promise<string> {
const secret = await assertSecretInCompany(companyId, secretId);
const resolvedVersion = version === "latest" ? secret.latestVersion : version;
const versionRow = await getSecretVersion(secret.id, resolvedVersion);
if (!versionRow) throw notFound("Secret version not found");
const provider = getSecretProvider(secret.provider as SecretProvider);
return provider.resolveVersion({
material: versionRow.material as Record<string, unknown>,
externalRef: secret.externalRef,
});
}
async function normalizeEnvConfig(
companyId: string,
envValue: unknown,
opts?: { strictMode?: boolean },
): Promise<AgentEnvConfig> {
const record = asRecord(envValue);
if (!record) throw unprocessable("adapterConfig.env must be an object");
const normalized: AgentEnvConfig = {};
for (const [key, rawBinding] of Object.entries(record)) {
if (!ENV_KEY_RE.test(key)) {
throw unprocessable(`Invalid environment variable name: ${key}`);
}
const parsed = envBindingSchema.safeParse(rawBinding);
if (!parsed.success) {
throw unprocessable(`Invalid environment binding for key: ${key}`);
}
const binding = canonicalizeBinding(parsed.data as EnvBinding);
if (binding.type === "plain") {
if (opts?.strictMode && isSensitiveEnvKey(key) && binding.value.trim().length > 0) {
throw unprocessable(
`Strict secret mode requires secret references for sensitive key: ${key}`,
);
}
if (binding.value === REDACTED_SENTINEL) {
throw unprocessable(`Refusing to persist redacted placeholder for key: ${key}`);
}
normalized[key] = binding;
continue;
}
await assertSecretInCompany(companyId, binding.secretId);
normalized[key] = {
type: "secret_ref",
secretId: binding.secretId,
version: binding.version,
};
}
return normalized;
}
async function normalizeAdapterConfigForPersistenceInternal(
companyId: string,
adapterConfig: Record<string, unknown>,
opts?: { strictMode?: boolean },
) {
const normalized = { ...adapterConfig };
if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
return normalized;
}
normalized.env = await normalizeEnvConfig(companyId, adapterConfig.env, opts);
return normalized;
}
return {
listProviders: () => listSecretProviders(),
list: (companyId: string) =>
db
.select()
.from(companySecrets)
.where(eq(companySecrets.companyId, companyId))
.orderBy(desc(companySecrets.createdAt)),
getById,
getByName,
create: async (
companyId: string,
input: {
name: string;
provider: SecretProvider;
value: string;
description?: string | null;
externalRef?: string | null;
},
actor?: { userId?: string | null; agentId?: string | null },
) => {
const existing = await getByName(companyId, input.name);
if (existing) throw conflict(`Secret already exists: ${input.name}`);
const provider = getSecretProvider(input.provider);
const prepared = await provider.createVersion({
value: input.value,
externalRef: input.externalRef ?? null,
});
return db.transaction(async (tx) => {
const secret = await tx
.insert(companySecrets)
.values({
companyId,
name: input.name,
provider: input.provider,
externalRef: prepared.externalRef,
latestVersion: 1,
description: input.description ?? null,
createdByAgentId: actor?.agentId ?? null,
createdByUserId: actor?.userId ?? null,
})
.returning()
.then((rows) => rows[0]);
await tx.insert(companySecretVersions).values({
secretId: secret.id,
version: 1,
material: prepared.material,
valueSha256: prepared.valueSha256,
createdByAgentId: actor?.agentId ?? null,
createdByUserId: actor?.userId ?? null,
});
return secret;
});
},
rotate: async (
secretId: string,
input: { value: string; externalRef?: string | null },
actor?: { userId?: string | null; agentId?: string | null },
) => {
const secret = await getById(secretId);
if (!secret) throw notFound("Secret not found");
const provider = getSecretProvider(secret.provider as SecretProvider);
const nextVersion = secret.latestVersion + 1;
const prepared = await provider.createVersion({
value: input.value,
externalRef: input.externalRef ?? secret.externalRef ?? null,
});
return db.transaction(async (tx) => {
await tx.insert(companySecretVersions).values({
secretId: secret.id,
version: nextVersion,
material: prepared.material,
valueSha256: prepared.valueSha256,
createdByAgentId: actor?.agentId ?? null,
createdByUserId: actor?.userId ?? null,
});
const updated = await tx
.update(companySecrets)
.set({
latestVersion: nextVersion,
externalRef: prepared.externalRef,
updatedAt: new Date(),
})
.where(eq(companySecrets.id, secret.id))
.returning()
.then((rows) => rows[0] ?? null);
if (!updated) throw notFound("Secret not found");
return updated;
});
},
update: async (
secretId: string,
patch: { name?: string; description?: string | null; externalRef?: string | null },
) => {
const secret = await getById(secretId);
if (!secret) throw notFound("Secret not found");
if (patch.name && patch.name !== secret.name) {
const duplicate = await getByName(secret.companyId, patch.name);
if (duplicate && duplicate.id !== secret.id) {
throw conflict(`Secret already exists: ${patch.name}`);
}
}
return db
.update(companySecrets)
.set({
name: patch.name ?? secret.name,
description:
patch.description === undefined ? secret.description : patch.description,
externalRef:
patch.externalRef === undefined ? secret.externalRef : patch.externalRef,
updatedAt: new Date(),
})
.where(eq(companySecrets.id, secret.id))
.returning()
.then((rows) => rows[0] ?? null);
},
remove: async (secretId: string) => {
const secret = await getById(secretId);
if (!secret) return null;
await db.delete(companySecrets).where(eq(companySecrets.id, secretId));
return secret;
},
normalizeAdapterConfigForPersistence: async (
companyId: string,
adapterConfig: Record<string, unknown>,
opts?: { strictMode?: boolean },
) => normalizeAdapterConfigForPersistenceInternal(companyId, adapterConfig, opts),
normalizeHireApprovalPayloadForPersistence: async (
companyId: string,
payload: Record<string, unknown>,
opts?: { strictMode?: boolean },
) => {
const normalized = { ...payload };
const adapterConfig = asRecord(payload.adapterConfig);
if (adapterConfig) {
normalized.adapterConfig = await normalizeAdapterConfigForPersistenceInternal(
companyId,
adapterConfig,
opts,
);
}
return normalized;
},
resolveEnvBindings: async (companyId: string, envValue: unknown) => {
const record = asRecord(envValue);
if (!record) return {} as Record<string, string>;
const resolved: Record<string, string> = {};
for (const [key, rawBinding] of Object.entries(record)) {
if (!ENV_KEY_RE.test(key)) {
throw unprocessable(`Invalid environment variable name: ${key}`);
}
const parsed = envBindingSchema.safeParse(rawBinding);
if (!parsed.success) {
throw unprocessable(`Invalid environment binding for key: ${key}`);
}
const binding = canonicalizeBinding(parsed.data as EnvBinding);
if (binding.type === "plain") {
resolved[key] = binding.value;
} else {
resolved[key] = await resolveSecretValue(companyId, binding.secretId, binding.version);
}
}
return resolved;
},
resolveAdapterConfigForRuntime: async (companyId: string, adapterConfig: Record<string, unknown>) => {
const resolved = { ...adapterConfig };
if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) {
return resolved;
}
const record = asRecord(adapterConfig.env);
if (!record) {
resolved.env = {};
return resolved;
}
const env: Record<string, string> = {};
for (const [key, rawBinding] of Object.entries(record)) {
if (!ENV_KEY_RE.test(key)) {
throw unprocessable(`Invalid environment variable name: ${key}`);
}
const parsed = envBindingSchema.safeParse(rawBinding);
if (!parsed.success) {
throw unprocessable(`Invalid environment binding for key: ${key}`);
}
const binding = canonicalizeBinding(parsed.data as EnvBinding);
if (binding.type === "plain") {
env[key] = binding.value;
} else {
env[key] = await resolveSecretValue(companyId, binding.secretId, binding.version);
}
}
resolved.env = env;
return resolved;
},
};
}