diff --git a/doc/DATABASE.md b/doc/DATABASE.md index db53ef3e..2406c318 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -120,3 +120,39 @@ The database mode is controlled by `DATABASE_URL`: | `postgres://...supabase.com...` | Hosted Supabase | Your Drizzle schema (`packages/db/src/schema/`) stays the same regardless of mode. + +## Secret storage + +Paperclip stores secret metadata and versions in: + +- `company_secrets` +- `company_secret_versions` + +For local/default installs, the active provider is `local_encrypted`: + +- Secret material is encrypted at rest with a local master key. +- Default key file: `./data/secrets/master.key` (auto-created if missing). +- CLI config location: `.paperclip/config.json` under `secrets.localEncrypted.keyFilePath`. + +Optional overrides: + +- `PAPERCLIP_SECRETS_MASTER_KEY` (32-byte key as base64, hex, or raw 32-char string) +- `PAPERCLIP_SECRETS_MASTER_KEY_FILE` (custom key file path) + +Strict mode to block new inline sensitive env values: + +```sh +PAPERCLIP_SECRETS_STRICT_MODE=true +``` + +You can set strict mode and provider defaults via: + +```sh +pnpm paperclip configure --section secrets +``` + +Inline secret migration command: + +```sh +pnpm secrets:migrate-inline-env --apply +``` diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index b1de3ad2..93f1cedd 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -56,3 +56,32 @@ pnpm dev ## Optional: Use External Postgres If you set `DATABASE_URL`, the server will use that instead of embedded PostgreSQL. + +## Secrets in Dev + +Agent env vars now support secret references. By default, secret values are stored with local encryption and only secret refs are persisted in agent config. + +- Default local key path: `./data/secrets/master.key` +- Override key material directly: `PAPERCLIP_SECRETS_MASTER_KEY` +- Override key file path: `PAPERCLIP_SECRETS_MASTER_KEY_FILE` + +Strict mode (recommended outside local trusted machines): + +```sh +PAPERCLIP_SECRETS_STRICT_MODE=true +``` + +When strict mode is enabled, sensitive env keys (for example `*_API_KEY`, `*_TOKEN`, `*_SECRET`) must use secret references instead of inline plain values. + +CLI configuration support: + +- `pnpm paperclip onboard` writes a default `secrets` config section (`local_encrypted`, strict mode off, key file path set) and creates a local key file when needed. +- `pnpm paperclip configure --section secrets` lets you update provider/strict mode/key path and creates the local key file when needed. +- `pnpm paperclip doctor` validates secrets adapter configuration and can create a missing local key file with `--repair`. + +Migration helper for existing inline env secrets: + +```sh +pnpm secrets:migrate-inline-env # dry run +pnpm secrets:migrate-inline-env --apply # apply migration +``` diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index a6ebe02a..7501e0be 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -275,7 +275,21 @@ Invariant: each event must attach to agent and company; rollups are aggregation, - `details` jsonb null - `created_at` timestamptz not null default now() -## 7.12 Required Indexes +## 7.12 `company_secrets` + `company_secret_versions` + +- Secret values are not stored inline in `agents.adapter_config.env`. +- Agent env entries should use secret refs for sensitive values. +- `company_secrets` tracks identity/provider metadata per company. +- `company_secret_versions` stores encrypted/reference material per version. +- Default provider in local deployments: `local_encrypted`. + +Operational policy: + +- Config read APIs redact sensitive plain values. +- Activity and approval payloads must not persist raw sensitive values. +- Config revisions may include redacted placeholders; such revisions are non-restorable for redacted fields. + +## 7.13 Required Indexes - `agents(company_id, status)` - `agents(company_id, reports_to)` @@ -288,6 +302,8 @@ Invariant: each event must attach to agent and company; rollups are aggregation, - `heartbeat_runs(company_id, agent_id, started_at desc)` - `approvals(company_id, status, type)` - `activity_log(company_id, created_at desc)` +- `company_secrets(company_id, name)` unique +- `company_secret_versions(secret_id, version)` unique ## 8. State Machines diff --git a/package.json b/package.json index 1c85c9d2..333870a8 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test:run": "vitest run", "db:generate": "pnpm --filter @paperclip/db generate", "db:migrate": "pnpm --filter @paperclip/db migrate", + "secrets:migrate-inline-env": "tsx scripts/migrate-inline-env-secrets.ts", "db:backup": "./scripts/backup-db.sh", "paperclip": "node cli/node_modules/tsx/dist/cli.mjs cli/src/index.ts" }, diff --git a/scripts/migrate-inline-env-secrets.ts b/scripts/migrate-inline-env-secrets.ts new file mode 100644 index 00000000..add77560 --- /dev/null +++ b/scripts/migrate-inline-env-secrets.ts @@ -0,0 +1,125 @@ +import { eq } from "drizzle-orm"; +import { agents, createDb } from "@paperclip/db"; +import { secretService } from "../server/src/services/secrets.js"; + +const SENSITIVE_ENV_KEY_RE = + /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; + +type EnvBinding = + | string + | { type: "plain"; value: string } + | { type: "secret_ref"; secretId: string; version?: number | "latest" }; + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function toPlainValue(binding: unknown): string | null { + if (typeof binding === "string") return binding; + if (typeof binding !== "object" || binding === null || Array.isArray(binding)) return null; + const rec = binding as Record; + if (rec.type === "plain" && typeof rec.value === "string") return rec.value; + return null; +} + +function secretName(agentId: string, key: string) { + return `agent_${agentId.slice(0, 8)}_${key.toLowerCase()}`; +} + +async function main() { + const dbUrl = process.env.DATABASE_URL; + if (!dbUrl) { + console.error("DATABASE_URL is required"); + process.exit(1); + } + + const apply = process.argv.includes("--apply"); + const db = createDb(dbUrl); + const secrets = secretService(db); + + const allAgents = await db.select().from(agents); + let changedAgents = 0; + let createdSecrets = 0; + let rotatedSecrets = 0; + + for (const agent of allAgents) { + const adapterConfig = asRecord(agent.adapterConfig); + if (!adapterConfig) continue; + const env = asRecord(adapterConfig.env); + if (!env) continue; + + let changed = false; + const nextEnv: Record = { ...(env as Record) }; + + for (const [key, rawBinding] of Object.entries(env)) { + if (!SENSITIVE_ENV_KEY_RE.test(key)) continue; + const plain = toPlainValue(rawBinding); + if (plain === null) continue; + if (plain.trim().length === 0) continue; + + const name = secretName(agent.id, key); + if (apply) { + const existing = await secrets.getByName(agent.companyId, name); + if (existing) { + await secrets.rotate( + existing.id, + { value: plain }, + { userId: "migration", agentId: null }, + ); + rotatedSecrets += 1; + nextEnv[key] = { type: "secret_ref", secretId: existing.id, version: "latest" }; + } else { + const created = await secrets.create( + agent.companyId, + { + name, + provider: "local_encrypted", + value: plain, + description: `Migrated from agent ${agent.id} env ${key}`, + }, + { userId: "migration", agentId: null }, + ); + createdSecrets += 1; + nextEnv[key] = { type: "secret_ref", secretId: created.id, version: "latest" }; + } + } else { + nextEnv[key] = { + type: "secret_ref", + secretId: ``, + version: "latest", + }; + } + changed = true; + } + + if (!changed) continue; + changedAgents += 1; + + if (apply) { + await db + .update(agents) + .set({ + adapterConfig: { + ...adapterConfig, + env: nextEnv, + }, + updatedAt: new Date(), + }) + .where(eq(agents.id, agent.id)); + } + } + + if (!apply) { + console.log(`Dry run: ${changedAgents} agents would be updated`); + console.log("Re-run with --apply to persist changes"); + process.exit(0); + } + + console.log( + `Updated ${changedAgents} agents, created ${createdSecrets} secrets, rotated ${rotatedSecrets} secrets`, + ); + process.exit(0); +} + +void main();