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

@@ -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, {