diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts index 0fb6b2aee32..a2164aa7e4e 100644 --- a/cli/src/__tests__/worktree.test.ts +++ b/cli/src/__tests__/worktree.test.ts @@ -511,7 +511,8 @@ describe("worktree helpers", () => { expect(minimal.excludedTables).toContain("agent_task_sessions"); expect(minimal.nullifyColumns.issues).toEqual(["checkout_run_id", "execution_run_id"]); - expect(full.excludedTables).toEqual([]); + expect(full.excludedTables).toEqual(["agent_identity_keys"]); + expect(minimal.excludedTables).toContain("agent_identity_keys"); expect(full.nullifyColumns).toEqual({}); }); @@ -2045,8 +2046,8 @@ describe("worktree helpers", () => { } }); - it("uses streaming backup selection for full seeds and transformed backup selection for minimal seeds", () => { - expect(resolveWorktreeSeedBackupEngine(resolveWorktreeSeedPlan("full"))).toBe("auto"); + it("uses transformed backups for both seed modes to omit agent identities", () => { + expect(resolveWorktreeSeedBackupEngine(resolveWorktreeSeedPlan("full"))).toBe("javascript"); expect(resolveWorktreeSeedBackupEngine(resolveWorktreeSeedPlan("minimal"))).toBe("javascript"); }); diff --git a/cli/src/commands/worktree-lib.ts b/cli/src/commands/worktree-lib.ts index 4cdf7ef5364..8d3aa01f6fe 100644 --- a/cli/src/commands/worktree-lib.ts +++ b/cli/src/commands/worktree-lib.ts @@ -58,6 +58,7 @@ export type WorktreeSeedPlan = { }; const MINIMAL_WORKTREE_EXCLUDED_TABLES = [ + "agent_identity_keys", "activity_log", "agent_runtime_state", "agent_task_sessions", @@ -118,7 +119,7 @@ export function resolveWorktreeSeedPlan(mode: WorktreeSeedMode): WorktreeSeedPla if (mode === "full") { return { mode, - excludedTables: [], + excludedTables: ["agent_identity_keys"], nullifyColumns: {}, }; } diff --git a/doc/AGENT-IDENTITY.md b/doc/AGENT-IDENTITY.md new file mode 100644 index 00000000000..c10108092dd --- /dev/null +++ b/doc/AGENT-IDENTITY.md @@ -0,0 +1,86 @@ +# Agent cryptographic identity + +Each agent has one persistent Ed25519 keypair. New agents receive it in the +agent-creation transaction. Existing agents receive it when Paperclip prepares +their next managed run. Migration, startup, public API reads, and opening the +agent page do not provision existing agents. There is no backfill command. + +## Storage + +`agent_identity_keys` lives in the agent's home instance database, including a +hosted stack's database. Its agent primary key and composite company/agent +foreign key enforce one identity and company-consistent ownership. Deleting an +agent cascades to its identity. Creation locks the agent row inside a transaction, +so simultaneous first runs use the same stored pair. + +Public keys use SPKI PEM and private keys use PKCS#8 PEM. `keyId` is `sha256:` +followed by the base64url SHA-256 digest of the public key's SPKI DER bytes. +Private material is encrypted using the existing `local_encrypted` provider and +stored separately from company secrets. The wrapping key is the stack's +`PAPERCLIP_SECRETS_MASTER_KEY` or the local instance's secrets master-key file. +The file is published atomically with mode `0600` on first use. No per-agent +Cloud secret or Cloud registry record is needed. + +Decryption and public/private key validation happen before launch. A wrong +master key, corrupt ciphertext, or mismatched public key fails the run; none +causes automatic key replacement. Renames, model changes, pauses, resumes, and +restarts preserve the keypair. + +## Managed runs + +Paperclip supplies these server-owned environment variables: + +```text +PAPERCLIP_AGENT_KEY_ID +PAPERCLIP_AGENT_PUBLIC_KEY +PAPERCLIP_AGENT_PRIVATE_KEY +``` + +The two key values are multiline PEM strings. Runtime-only adapter context and +native runner environment fields deliver them to managed processes. Configured +or inherited environment values cannot replace the assigned identity. Session +compatibility includes the public key ID, so an older warm process is replaced +before its next turn. Private values are excluded from invocation metadata and +execution configuration; output and failure diagnostics redact known private +material. The run-log redactor handles split stdout/stderr chunks independently. + +Independently hosted HTTP and gateway agents, including Runner's API-hosted Claude Managed and AWS AgentCore providers, do not receive private keys in v1. +Their new agent records still get stored identities; their existing records stay +unprovisioned until a supported managed run. + +An agent can sign using an ordinary cryptographic library, for example Node.js: + +```js +import { sign } from "node:crypto"; +const signature = sign(null, challengeBytes, process.env.PAPERCLIP_AGENT_PRIVATE_KEY); +``` + +This identity does not change Paperclip bearer-token authentication. There is no +signing API, private-key download endpoint, Git integration, rotation UI, or +external-key import. Agents possess their private keys. External consumers must +define their own authorization, expiry, and revocation rules. + +## Public identity + +`GET /api/agents/:id/identity` applies the existing agent-read access checks. It +returns `null` before provisioning, otherwise: + +```ts +{ algorithm: "Ed25519", keyId: string, publicKeyPem: string, createdAt: string } +``` + +The agent's Identity section shows a shortened fingerprint and copies the full +public PEM. Before provisioning it shows “Not created yet” and explains that +creation happens on the next managed run. + +## Copies and recovery + +Company/template imports and duplication use normal agent creation and get new +identities. Minimal and full development seeds omit identity **rows**, while +retaining the schema, even with live-work preservation. Those copied agents get +fresh identities on their next managed runs. + +Ordinary disaster-recovery backups include encrypted identities. Recovery needs +both the database and the matching wrapping key; back up the key separately. +Do not use a disaster-recovery backup as a development clone when agents should +have distinct identities. diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 2c87b1a3c70..e99cefd9127 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -566,3 +566,12 @@ reservation cannot silently disappear. Failed cleanup or an ambiguous storage write requires operator reconciliation before an unattached reservation is removed. The table stores no response bodies. See `doc/runner-api-tools.md` for limits and the operator override. + +## Agent identity keys and backups + +`agent_identity_keys` stores one encrypted Ed25519 identity per agent. Its migration +creates schema only: existing agents provision on their next managed run. Public +reads and server startup do not provision them. Normal backups preserve identity +rows and need the matching secrets master key for recovery. Both development seed +modes omit identity rows, including with live-work preservation, so copied agents +get fresh identities. See [Agent cryptographic identity](AGENT-IDENTITY.md). diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 3fac229ead7..f3ce1168db3 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -188,6 +188,18 @@ Invariants: Invariant: plaintext key shown once at creation; only hash stored. +### Agent cryptographic identity + +Each agent also has one Ed25519 identity in `agent_identity_keys`, separate from +API bearer keys and company secrets. New-agent creation provisions it atomically; +existing agents provision lazily before their first managed run. The schema-only +migration and public reads never provision existing agents. Private PKCS#8 PEM +material uses `local_encrypted`; public SPKI PEM and its SHA-256 key ID are readable +through `GET /api/agents/:id/identity` and the agent Identity section. Managed +processes receive the pair through runtime-only environment fields. See +[Agent cryptographic identity](AGENT-IDENTITY.md) for storage, runtime, and copy +semantics. + ## 7.4 `goals` - `id` uuid pk diff --git a/doc/SPEC.md b/doc/SPEC.md index 844aa3a1d2c..e32f9eef561 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -85,6 +85,15 @@ The Board sets Company-level budgets. The CEO can set budgets for Agents below t Every employee is an agent. Agents are the workforce. +### Cryptographic identity + +Agents have persistent Ed25519 identities, encrypted in their home instance. +New agents receive keys during creation; existing agents receive them lazily on +their next managed run. Agents can read their keys from the managed process +environment, and authorized readers can view or copy the public key from the +agent page. This does not grant external authorization or change bearer-token +access. See [the implementation contract](AGENT-IDENTITY.md). + ### Agent Identity (Adapter-Level) Concepts like SOUL.md (identity/mission) and HEARTBEAT.md (loop definition) are **not part of the Paperclip protocol**. They are adapter-specific configurations. For example, an OpenClaw adapter might use SOUL.md and HEARTBEAT.md files. A Claude Code adapter might use CLAUDE.md. A bare Python script might use command-line args. diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 28e5b620259..a08e17bbea6 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -1491,9 +1491,10 @@ function buildCodexStartupConfig(input: { requestedModel: string; requestedThinkingEffort: string; fastMode: boolean; + identityEnvironmentKeys?: string[]; }): { value: string | null; invalidExistingConfig: boolean } { const hasRuntimeConfig = Boolean( - input.requestedModel || input.requestedThinkingEffort || input.fastMode, + input.requestedModel || input.requestedThinkingEffort || input.fastMode || input.identityEnvironmentKeys, ); if (!hasRuntimeConfig) return { value: null, invalidExistingConfig: false }; @@ -1524,6 +1525,11 @@ function buildCodexStartupConfig(input: { }, } : {}), + ...(input.identityEnvironmentKeys ? { + features: { ...parseObject(existing.features), ...(input.fastMode ? { fast_mode: true } : {}), shell_snapshot: false }, + shell_environment_policy: { ...parseObject(existing.shell_environment_policy), + inherit: "all", ignore_default_excludes: true, include_only: input.identityEnvironmentKeys }, + } : {}), }), invalidExistingConfig, }; @@ -1959,7 +1965,7 @@ async function buildRuntime(input: { await fs.mkdir(stateDir, { recursive: true }); const envConfig = parseObject(config.env); - const env: Record = { ...buildPaperclipEnv(agent), PAPERCLIP_RUN_ID: runId }; + const env: Record = { ...buildPaperclipEnv(agent, input.ctx.agentIdentity), PAPERCLIP_RUN_ID: runId }; const wakeTaskId = (typeof context.taskId === "string" && context.taskId.trim()) || (typeof context.issueId === "string" && context.issueId.trim()) || @@ -2042,6 +2048,11 @@ async function buildRuntime(input: { if (acpxAgent === "codex") { const codexStartupConfig = buildCodexStartupConfig({ existingConfig: env.CODEX_CONFIG, + ...(input.ctx.agentIdentity ? { identityEnvironmentKeys: [...new Set([ + "PATH", "HOME", "LANG", "TMPDIR", "CODEX_HOME", + "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", + ...Object.keys(env).filter(key => !/key|secret|token/i.test(key)), + ])] } : {}), requestedModel, requestedThinkingEffort, fastMode, @@ -2203,6 +2214,7 @@ async function buildRuntime(input: { // identifiers are NOT here; they scope the outer session key only (see // `keyIdentity`). The fingerprint builder accepts only this identity. const fingerprintIdentity: SessionFingerprintIdentity = { + ...(input.ctx.agentIdentity ? { agentIdentityKeyId: input.ctx.agentIdentity.keyId } : {}), acpxAgent, agentCommand: agentCommand ?? acpxAgent, cwd: path.resolve(sessionCwd), diff --git a/packages/adapter-utils/src/acpx-engine/run-contracts.ts b/packages/adapter-utils/src/acpx-engine/run-contracts.ts index 173cb5db9aa..1610ed7d911 100644 --- a/packages/adapter-utils/src/acpx-engine/run-contracts.ts +++ b/packages/adapter-utils/src/acpx-engine/run-contracts.ts @@ -439,6 +439,7 @@ export interface PaperclipClaudeSettingsIdentity { * or a resumable session and forces a fresh launch. */ export interface SessionFingerprintIdentity { + readonly agentIdentityKeyId?: string; readonly acpxAgent: string; readonly agentCommand: string; readonly cwd: string; diff --git a/packages/adapter-utils/src/execution-target-sandbox.test.ts b/packages/adapter-utils/src/execution-target-sandbox.test.ts index c1599a25d8b..51ab309890f 100644 --- a/packages/adapter-utils/src/execution-target-sandbox.test.ts +++ b/packages/adapter-utils/src/execution-target-sandbox.test.ts @@ -1,3 +1,5 @@ +import { generateKeyPairSync, randomBytes, verify } from "node:crypto"; +import { buildAgentIdentityEnv } from "./server-utils.js"; import { createServer } from "node:http"; import http2 from "node:http2"; import net from "node:net"; @@ -308,6 +310,29 @@ describe("sandbox adapter execution targets", () => { return events.filter((event) => event.stream === stream).map((event) => event.chunk).join(""); } + it("delivers cryptographic identity through a remote sandbox process transport", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "identity-remote-process-")); + cleanupDirs.push(rootDir); + const keys = generateKeyPairSync("ed25519"); + const identity = { + keyId: "sha256:remote-identity", + publicKeyPem: keys.publicKey.export({ type: "spki", format: "pem" }).toString(), + privateKeyPem: keys.privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + }; + const target: AdapterSandboxExecutionTarget = { + kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, + timeoutMs: 30_000, runner: createLocalSandboxRunner(), + }; + const challenge = randomBytes(32); + const result = await runAdapterExecutionTargetProcess("identity-remote", target, process.execPath, + ["-e", `const {sign}=require('node:crypto');process.stdout.write(sign(null,Buffer.from(process.argv[1],'hex'),process.env.PAPERCLIP_AGENT_PRIVATE_KEY).toString('base64'));`, challenge.toString("hex")], { + cwd: rootDir, env: buildAgentIdentityEnv(identity), timeoutSec: 5, graceSec: 1, onLog: async () => {}, + }); + expect(result.exitCode).toBe(0); + expect(verify(null, challenge, keys.publicKey, Buffer.from(result.stdout, "base64"))).toBe(true); + expect((await readRuntimeTextFiles(rootDir)).join("\n")).not.toContain(identity.privateKeyPem.split("\n")[1]); + }); + it("executes through the provider-neutral runner without a remote spec", async () => { const runner = { execute: vi.fn(async () => ({ diff --git a/packages/adapter-utils/src/index.ts b/packages/adapter-utils/src/index.ts index eb02fb4efc4..1fb3046e1ed 100644 --- a/packages/adapter-utils/src/index.ts +++ b/packages/adapter-utils/src/index.ts @@ -1,5 +1,6 @@ export type { AdapterAgent, + AgentRuntimeIdentity, AdapterRuntime, UsageSummary, AdapterBillingType, diff --git a/packages/adapter-utils/src/server-utils.test.ts b/packages/adapter-utils/src/server-utils.test.ts index c35e4e2a8f6..520414d3b72 100644 --- a/packages/adapter-utils/src/server-utils.test.ts +++ b/packages/adapter-utils/src/server-utils.test.ts @@ -31,6 +31,8 @@ import { runningProcesses, runChildProcess, sanitizeSshRemoteEnv, + sanitizeInheritedPaperclipEnv, + isForbiddenConfigEnvKey, signalRunningProcess, shapePaperclipWorkspaceEnvForExecution, rewriteWorkspaceCwdEnvVarsForExecution, @@ -40,6 +42,13 @@ import { WATCHDOG_DEFAULT_MANDATE, } from "./server-utils.js"; +it("reserves identity credentials even for mixed-case inherited or configured keys", () => { + const keys = ["PAPERCLIP_AGENT_KEY_ID", "Paperclip_Agent_Public_Key", "paperclip_agent_private_key"]; + const inherited = Object.fromEntries(keys.map(key => [key, "host-override"])); + expect(sanitizeInheritedPaperclipEnv({ ...inherited, PATH: "/usr/bin" })).toEqual({ PATH: "/usr/bin" }); + for (const key of keys) expect(isForbiddenConfigEnvKey(key)).toBe(true); +}); + describe("runtime connection tool delivery", () => { const access = { version: 1 as const, diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index d07a634e825..e14d77d0d24 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -20,6 +20,7 @@ import { } from "./paperclip-runner-permissions.js"; import type { AdapterExecutionContext, + AgentRuntimeIdentity, AdapterRuntimeToolAccess, AdapterSkillEntry, AdapterSkillSnapshot, @@ -168,7 +169,8 @@ export function isPaperclipRuntimeEnvKey(key: string): boolean { // Other PAPERCLIP_*-named config keys are allowed as long as Paperclip has // not assigned the same key for the run (runtime vars always win). export function isForbiddenConfigEnvKey(key: string): boolean { - return key === "PAPERCLIP_API_KEY" || key === "PAPERCLIP_WAKE_PAYLOAD_JSON"; + return key === "PAPERCLIP_API_KEY" || key === "PAPERCLIP_WAKE_PAYLOAD_JSON" || + AGENT_IDENTITY_ENV_KEYS.includes(key.toUpperCase()); } const PAPERCLIP_SKILL_ROOT_RELATIVE_CANDIDATES = [ "../../skills", @@ -3193,10 +3195,22 @@ export function buildInvocationEnvForLogs( return redactEnvForLogs(merged); } +export const AGENT_IDENTITY_ENV_KEYS = [ + "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", +]; + +export function buildAgentIdentityEnv(identity?: AgentRuntimeIdentity): Record { + return identity ? { + PAPERCLIP_AGENT_KEY_ID: identity.keyId, + PAPERCLIP_AGENT_PUBLIC_KEY: identity.publicKeyPem, + PAPERCLIP_AGENT_PRIVATE_KEY: identity.privateKeyPem, + } : {}; +} + export function buildPaperclipEnv(agent: { id: string; companyId: string; -}): Record { +}, identity?: AgentRuntimeIdentity): Record { const resolveHostForUrl = (rawHost: string): string => { const host = rawHost.trim(); if (!host || host === "0.0.0.0" || host === "::") return "localhost"; @@ -3207,6 +3221,7 @@ export function buildPaperclipEnv(agent: { const vars: Record = { PAPERCLIP_AGENT_ID: agent.id, PAPERCLIP_COMPANY_ID: agent.companyId, + ...buildAgentIdentityEnv(identity), }; const runtimeHost = resolveHostForUrl( process.env.PAPERCLIP_LISTEN_HOST ?? process.env.HOST ?? "localhost", @@ -3495,6 +3510,10 @@ export function sanitizeInheritedPaperclipEnv( const env: NodeJS.ProcessEnv = { ...baseEnv }; delete env.PAPERCLIPAI_CMD; for (const key of Object.keys(env)) { + if (AGENT_IDENTITY_ENV_KEYS.includes(key.toUpperCase())) { + delete env[key]; + continue; + } if (!key.startsWith("PAPERCLIP_")) continue; if (key === "PAPERCLIP_RUNTIME_API_URL") continue; if (key === "PAPERCLIP_LISTEN_HOST") continue; diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index c11343cae4e..6b068adcd3b 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -196,7 +196,15 @@ export interface AdapterRuntimeEvent { payload?: Record; } +export interface AgentRuntimeIdentity { + keyId: string; + publicKeyPem: string; + privateKeyPem: string; +} + export interface AdapterExecutionContext { + /** Server-owned credentials. Never persist this object or merge it into config. */ + agentIdentity?: AgentRuntimeIdentity; /** Synchronous, content-free diagnostic scope; never stop or collection authority. */ onExecutionPhase?: AdapterExecutionPhaseSink; /** Run-scoped operator cancellation; adapters must settle before returning. */ diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 114cad4fa43..988c2479f5a 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -104,6 +104,7 @@ const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const executeClaudeAcp = createClaudeAcpExecutor(); interface ClaudeExecutionInput { + agentIdentity?: AdapterExecutionContext["agentIdentity"]; runId: string; agent: AdapterExecutionContext["agent"]; config: Record; @@ -216,7 +217,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise = { ...buildPaperclipEnv(agent) }; + const env: Record = { ...buildPaperclipEnv(agent, input.agentIdentity) }; env.PAPERCLIP_RUN_ID = runId; const wakeTaskId = @@ -456,6 +457,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise ({ name: server.name, endpointPath: server.url, @@ -1228,6 +1228,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise identityNames.includes(key) || !/key|secret|token/i.test(key)); + args.unshift("-c", "features.shell_snapshot=false", "-c", 'shell_environment_policy.inherit="all"', "-c", "shell_environment_policy.ignore_default_excludes=true", + "-c", `shell_environment_policy.include_only=${JSON.stringify(shellKeys)}`); + } const commandNotesWithFastMode = execArgs.fastModeIgnoredReason == null ? commandNotes diff --git a/packages/adapters/cursor-cloud/src/server/execute.ts b/packages/adapters/cursor-cloud/src/server/execute.ts index 48e62b10eea..c4335fb71b8 100644 --- a/packages/adapters/cursor-cloud/src/server/execute.ts +++ b/packages/adapters/cursor-cloud/src/server/execute.ts @@ -108,7 +108,7 @@ function buildWakeEnv(ctx: AdapterExecutionContext, configEnv: Record = { ...configEnv, - ...buildPaperclipEnv(agent), + ...buildPaperclipEnv(agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), PAPERCLIP_RUN_ID: runId, }; diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index cfaa9949a9e..f0cbb4f4212 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -240,7 +240,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise = { - ...buildPaperclipEnv(agent), + ...buildPaperclipEnv(agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), }; env.PAPERCLIP_RUN_ID = runId; diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 720c384d433..7f0e915709d 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -273,7 +273,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise = { - ...buildPaperclipEnv(agent), + ...buildPaperclipEnv(agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), }; env.PAPERCLIP_RUN_ID = runId; diff --git a/packages/adapters/grok-local/src/server/execute.ts b/packages/adapters/grok-local/src/server/execute.ts index 1170900e5fc..f2009f46e8f 100644 --- a/packages/adapters/grok-local/src/server/execute.ts +++ b/packages/adapters/grok-local/src/server/execute.ts @@ -311,7 +311,7 @@ async function executeTurn(ctx: AdapterExecutionContext): Promise => { const envConfig = parseObject(config.env); const env: Record = { - ...buildPaperclipEnv(agent), + ...buildPaperclipEnv(agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), }; env.PAPERCLIP_RUN_ID = runId; diff --git a/packages/adapters/hermes/src/server/execute.ts b/packages/adapters/hermes/src/server/execute.ts index ca70427b8d1..d835b064bbe 100644 --- a/packages/adapters/hermes/src/server/execute.ts +++ b/packages/adapters/hermes/src/server/execute.ts @@ -487,7 +487,7 @@ export async function execute( const env: Record = { ...(process.env as Record), ...(userEnv && typeof userEnv === "object" ? userEnv : {}), - ...buildPaperclipEnv(ctx.agent), + ...buildPaperclipEnv(ctx.agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), }; diff --git a/packages/adapters/kimi-local/src/server/execute.ts b/packages/adapters/kimi-local/src/server/execute.ts index b02ca0c02da..96a28c127b9 100644 --- a/packages/adapters/kimi-local/src/server/execute.ts +++ b/packages/adapters/kimi-local/src/server/execute.ts @@ -246,7 +246,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const env: Record = { - ...buildPaperclipEnv(agent), + ...buildPaperclipEnv(agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), }; env.PAPERCLIP_RUN_ID = runId; diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 6a1f0dc1c0a..2c6a599e07b 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -272,7 +272,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise = { - ...buildPaperclipEnv(agent), + ...buildPaperclipEnv(agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), }; env.PAPERCLIP_RUN_ID = runId; diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 3149642b323..dc00a9946f1 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -277,7 +277,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise = { - ...buildPaperclipEnv(agent), + ...buildPaperclipEnv(agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), }; env.PAPERCLIP_RUN_ID = runId; diff --git a/packages/db/src/migrations/0306_windy_leo.sql b/packages/db/src/migrations/0306_windy_leo.sql new file mode 100644 index 00000000000..81c5ef748ae --- /dev/null +++ b/packages/db/src/migrations/0306_windy_leo.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS "agent_identity_keys" ( + "agent_id" uuid PRIMARY KEY NOT NULL, + "company_id" uuid NOT NULL, + "algorithm" text NOT NULL, + "key_id" text NOT NULL, + "public_key_pem" text NOT NULL, + "private_key_material" jsonb NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "agent_identity_keys_algorithm_check" CHECK ("agent_identity_keys"."algorithm" = 'Ed25519') +); +--> statement-breakpoint +DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'public.agent_identity_keys'::regclass + AND conname = 'agent_identity_keys_owner_fk' + ) THEN + ALTER TABLE "agent_identity_keys" ADD CONSTRAINT "agent_identity_keys_owner_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action; + END IF; +END $$; diff --git a/packages/db/src/migrations/meta/0301_snapshot.json b/packages/db/src/migrations/meta/0306_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0301_snapshot.json rename to packages/db/src/migrations/meta/0306_snapshot.json index 6a4bf1f5c1e..7078d40338b 100644 --- a/packages/db/src/migrations/meta/0301_snapshot.json +++ b/packages/db/src/migrations/meta/0306_snapshot.json @@ -1,6 +1,6 @@ { - "id": "357aa875-870e-4232-9b3f-4194a2c74844", - "prevId": "dfa5064e-7b35-4d20-8a3d-aa808c4d92e0", + "id": "597fc11f-c141-4357-b4f0-e500a4d84658", + "prevId": "392e3276-35d9-41b8-b703-9d592081bae8", "version": "7", "dialect": "postgresql", "tables": { @@ -829,6 +829,83 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.agent_identity_keys": { + "name": "agent_identity_keys", + "schema": "", + "columns": { + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key_pem": { + "name": "public_key_pem", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_material": { + "name": "private_key_material", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "agent_identity_keys_owner_fk": { + "name": "agent_identity_keys_owner_fk", + "tableFrom": "agent_identity_keys", + "tableTo": "agents", + "columnsFrom": [ + "company_id", + "agent_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "agent_identity_keys_algorithm_check": { + "name": "agent_identity_keys_algorithm_check", + "value": "\"agent_identity_keys\".\"algorithm\" = 'Ed25519'" + } + }, + "isRLSEnabled": false + }, "public.agent_instruction_heads": { "name": "agent_instruction_heads", "schema": "", @@ -51112,6 +51189,142 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.mcp_event_admissions": { + "name": "mcp_event_admissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reserves_subscription": { + "name": "reserves_subscription", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_event_admissions_expiry_idx": { + "name": "mcp_event_admissions_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_event_admissions_grant_idx": { + "name": "mcp_event_admissions_grant_idx", + "columns": [ + { + "expression": "grant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_event_admissions_company_idx": { + "name": "mcp_event_admissions_company_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_event_admissions_company_id_companies_id_fk": { + "name": "mcp_event_admissions_company_id_companies_id_fk", + "tableFrom": "mcp_event_admissions", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_event_admissions_grant_id_mcp_oauth_grants_id_fk": { + "name": "mcp_event_admissions_grant_id_mcp_oauth_grants_id_fk", + "tableFrom": "mcp_event_admissions", + "tableTo": "mcp_oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.mcp_event_deliveries": { "name": "mcp_event_deliveries", "schema": "", @@ -51565,6 +51778,13 @@ "primaryKey": false, "notNull": true }, + "grant_types": { + "name": "grant_types", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"authorization_code\",\"refresh_token\"]'::jsonb" + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -51581,6 +51801,181 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.mcp_oauth_device_requests": { + "name": "mcp_oauth_device_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code_hash": { + "name": "user_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scopes": { + "name": "scopes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "requested_company_id": { + "name": "requested_company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "interval_seconds": { + "name": "interval_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "next_poll_at": { + "name": "next_poll_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_oauth_device_code_uq": { + "name": "mcp_oauth_device_code_uq", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_user_code_uq": { + "name": "mcp_oauth_user_code_uq", + "columns": [ + { + "expression": "user_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_oauth_device_expiry_idx": { + "name": "mcp_oauth_device_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_oauth_device_requests_client_id_mcp_oauth_clients_id_fk": { + "name": "mcp_oauth_device_requests_client_id_mcp_oauth_clients_id_fk", + "tableFrom": "mcp_oauth_device_requests", + "tableTo": "mcp_oauth_clients", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_oauth_device_requests_grant_id_mcp_oauth_grants_id_fk": { + "name": "mcp_oauth_device_requests_grant_id_mcp_oauth_grants_id_fk", + "tableFrom": "mcp_oauth_device_requests", + "tableTo": "mcp_oauth_grants", + "columnsFrom": [ + "grant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.mcp_oauth_grants": { "name": "mcp_oauth_grants", "schema": "", @@ -51706,6 +52101,54 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.mcp_oauth_metadata_admissions": { + "name": "mcp_oauth_metadata_admissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mcp_oauth_metadata_admissions_expiry_idx": { + "name": "mcp_oauth_metadata_admissions_expiry_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.mcp_oauth_requests": { "name": "mcp_oauth_requests", "schema": "", @@ -51752,6 +52195,12 @@ "primaryKey": false, "notNull": true }, + "requested_company_id": { + "name": "requested_company_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "grant_id": { "name": "grant_id", "type": "uuid", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index bb045d8e1df..ea487eed037 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2129,6 +2129,13 @@ "when": 1791300719193, "tag": "0305_chubby_vin_gonzales", "breakpoints": true + }, + { + "idx": 306, + "version": "7", + "when": 1791308373750, + "tag": "0306_windy_leo", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/agent_identity_keys.ts b/packages/db/src/schema/agent_identity_keys.ts new file mode 100644 index 00000000000..0cc9250a1b5 --- /dev/null +++ b/packages/db/src/schema/agent_identity_keys.ts @@ -0,0 +1,20 @@ +import { sql } from "drizzle-orm"; +import { pgTable, uuid, text, timestamp, jsonb, foreignKey, check } from "drizzle-orm/pg-core"; +import { agents } from "./agents.js"; + +export const agentIdentityKeys = pgTable("agent_identity_keys", { + agentId: uuid("agent_id").primaryKey(), + companyId: uuid("company_id").notNull(), + algorithm: text("algorithm").$type<"Ed25519">().notNull(), + keyId: text("key_id").notNull(), + publicKeyPem: text("public_key_pem").notNull(), + privateKeyMaterial: jsonb("private_key_material").$type>().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + owner: foreignKey({ + name: "agent_identity_keys_owner_fk", + columns: [table.companyId, table.agentId], + foreignColumns: [agents.companyId, agents.id], + }).onDelete("cascade"), + algorithmCheck: check("agent_identity_keys_algorithm_check", sql`${table.algorithm} = 'Ed25519'`), +})); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 65f43629ec4..0ef185057b9 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -225,3 +225,5 @@ export * from "./public_mcp.js"; export * from "./ai_connection_routing.js"; export { toolConnectionAppSnapshots } from "./tool_connection_app_snapshots.js"; export { toolConnectionAppSyncs } from "./tool_connection_app_syncs.js"; + +export { agentIdentityKeys } from "./agent_identity_keys.js"; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs index 9bb2c3b6c0b..98908dfdbdd 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_sidecar_transport.rs @@ -109,6 +109,9 @@ impl AcpxSidecarTransport { } }; let mut keys = vec![ + "PAPERCLIP_AGENT_KEY_ID", + "PAPERCLIP_AGENT_PUBLIC_KEY", + "PAPERCLIP_AGENT_PRIVATE_KEY", "LANGUAGE", "SSL_CERT_FILE", "SSL_CERT_DIR", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs index bf6d79a874e..09e490c992f 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs @@ -32,6 +32,9 @@ const WARM_ATTACHMENT_TAIL_DRAIN_LIMIT: usize = 256; const WARM_ATTACHMENT_QUIET_WINDOW: Duration = Duration::from_millis(10); const WARM_ATTACHMENT_DRAIN_DEADLINE: Duration = Duration::from_millis(100); const OPENCODE_PROVIDER_ENVIRONMENT_KEYS: &[&str] = &[ + "PAPERCLIP_AGENT_KEY_ID", + "PAPERCLIP_AGENT_PUBLIC_KEY", + "PAPERCLIP_AGENT_PRIVATE_KEY", "OPENROUTER_API_KEY", "PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", @@ -242,6 +245,8 @@ impl ProviderTraceSink { } fn frame(&mut self, direction: &str, raw: &[u8]) -> Option { + let redacted = crate::durable::redact_agent_identity_text(&String::from_utf8_lossy(raw)); + let raw = redacted.as_bytes(); if self.captured_bytes.saturating_add(raw.len()) > self.max_bytes { self.truncated = true; return None; @@ -792,6 +797,9 @@ const GITHUB_CREDENTIAL_ENVIRONMENT_KEYS: &[&str] = &[ ]; const CODEX_PROVIDER_ENVIRONMENT_KEYS: &[&str] = &[ + "PAPERCLIP_AGENT_KEY_ID", + "PAPERCLIP_AGENT_PUBLIC_KEY", + "PAPERCLIP_AGENT_PRIVATE_KEY", "CODEX_HOME", "OPENAI_API_KEY", "CODEX_API_KEY", diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs index 7cbe493ba05..1a47897bd0c 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/mod.rs @@ -16,8 +16,9 @@ pub use runner::{ TerminalDeliveryReconciliation, }; pub(crate) use state::{ - create_private_temporary_file, open_private_regular_file, redact_sensitive_text_values, - redact_text, sanitize_value, validate_semantic_tool_input, verify_private_directory, + create_private_temporary_file, open_private_regular_file, redact_agent_identity_text, + redact_sensitive_text_values, redact_text, sanitize_value, validate_semantic_tool_input, + verify_private_directory, }; pub use state::{ Command, CommandDisposition, DurableState, DurableStateStore, EventPriority, diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs index 894a00176c3..8ae20ab8046 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs @@ -1842,7 +1842,30 @@ pub(crate) fn redact_text(input: &str) -> String { redacted } +pub(crate) fn redact_agent_identity_text(input: &str) -> String { + let Ok(key) = std::env::var("PAPERCLIP_AGENT_PRIVATE_KEY") else { + return input.to_owned(); + }; + if key.is_empty() { + return input.to_owned(); + } + let escaped = serde_json::to_string(&key).unwrap_or_default(); + let mut result = input.replace(&key, "[REDACTED]"); + if escaped.len() > 2 { + result = result.replace(&escaped[1..escaped.len() - 1], "[REDACTED]"); + } + for line in key + .lines() + .filter(|line| !line.is_empty() && !line.starts_with("-----")) + { + result = result.replace(line, "[REDACTED]"); + } + result +} + pub(crate) fn redact_sensitive_text_values(input: &str) -> String { + let identity_redacted = redact_agent_identity_text(input); + let input = identity_redacted.as_str(); let normalized = input.to_ascii_lowercase(); let bytes = normalized.as_bytes(); let mut ranges: Vec<(usize, usize)> = Vec::new(); diff --git a/packages/paperclip-runner/src/agent-identity-environment.test.ts b/packages/paperclip-runner/src/agent-identity-environment.test.ts new file mode 100644 index 00000000000..ea82e078ecf --- /dev/null +++ b/packages/paperclip-runner/src/agent-identity-environment.test.ts @@ -0,0 +1,34 @@ +import { execFileSync } from "node:child_process"; +import { generateKeyPairSync, randomBytes, verify } from "node:crypto"; +import { afterEach, expect, it, vi } from "vitest"; +import { createSanitizedCodexEnvironment } from "./drivers/codex/app-server-transport.js"; +import { createIsolatedCodexAppServerArgs } from "./drivers/codex/codex-security-config.js"; +import { createSanitizedAcpxSpawnInput } from "./drivers/acpx/environment.js"; + +afterEach(() => vi.unstubAllEnvs()); + +it("native Codex and ACPX provider processes receive explicit identity and can sign", () => { + const keys = generateKeyPairSync("ed25519"); + const environment = { + PAPERCLIP_AGENT_KEY_ID: "sha256:test-key", + PAPERCLIP_AGENT_PUBLIC_KEY: keys.publicKey.export({ type: "spki", format: "pem" }).toString(), + PAPERCLIP_AGENT_PRIVATE_KEY: keys.privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + }; + const challenge = randomBytes(32); + const source = `const {sign}=require('node:crypto'); process.stdout.write(sign(null,Buffer.from(process.argv[1],'hex'),process.env.PAPERCLIP_AGENT_PRIVATE_KEY).toString('base64'));`; + for (const env of [createSanitizedCodexEnvironment(environment), createSanitizedAcpxSpawnInput(environment, "codex").env]) { + expect(env).toMatchObject(environment); + const signature = execFileSync(process.execPath, ["-e", source, challenge.toString("hex")], { env }); + expect(verify(null, challenge, keys.publicKey, Buffer.from(signature.toString(), "base64"))).toBe(true); + } + const args = createIsolatedCodexAppServerArgs(environment).join("\n"); + expect(args).toContain('shell_environment_policy.inherit="all"'); + expect(args).toContain("PAPERCLIP_AGENT_PRIVATE_KEY"); + expect(args).not.toContain(environment.PAPERCLIP_AGENT_PRIVATE_KEY.split("\n")[1]); +}); + +it("native launchers do not adopt identity from an ambient host environment", () => { + vi.stubEnv("PAPERCLIP_AGENT_PRIVATE_KEY", "ambient-forged-key"); + expect(createSanitizedCodexEnvironment().PAPERCLIP_AGENT_PRIVATE_KEY).toBeUndefined(); + expect(createSanitizedAcpxSpawnInput(undefined, "codex").env.PAPERCLIP_AGENT_PRIVATE_KEY).toBeUndefined(); +}); diff --git a/packages/paperclip-runner/src/contracts/native-execution.ts b/packages/paperclip-runner/src/contracts/native-execution.ts index 9d089fde854..60085da3b68 100644 --- a/packages/paperclip-runner/src/contracts/native-execution.ts +++ b/packages/paperclip-runner/src/contracts/native-execution.ts @@ -151,6 +151,7 @@ export interface NativeExecutionInputV1 { runId: string; issueId: string; agentId: string; + agentKeyId?: string; executionWorkspaceId: string; }; task: { @@ -344,7 +345,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput } const binding = record(input.binding, "input.binding"); - exactKeys(binding, ["companyId", "runId", "issueId", "agentId", "executionWorkspaceId"], "input.binding"); + exactKeys(binding, ["companyId", "runId", "issueId", "agentId", "agentKeyId", "executionWorkspaceId"], "input.binding"); const task = record(input.task, "input.task"); exactKeys(task, ["identifier", "title", "description", "prompt", "workMode"], "input.task"); const workspace = record(input.workspace, "input.workspace"); @@ -709,6 +710,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput runId: text(binding.runId, "input.binding.runId"), issueId: text(binding.issueId, "input.binding.issueId"), agentId: text(binding.agentId, "input.binding.agentId"), + ...(binding.agentKeyId === undefined ? {} : { agentKeyId: text(binding.agentKeyId, "input.binding.agentKeyId") }), executionWorkspaceId: text(binding.executionWorkspaceId, "input.binding.executionWorkspaceId"), }, task: { diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts index 03ba01a956c..dfc3a831262 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.test.ts @@ -390,6 +390,9 @@ it("preserves an explicit OpenCode permission mode at the runner spawn boundary" runnerDigest: expectedRunnerDigest, environment: { PATH: "/bin", + PAPERCLIP_AGENT_KEY_ID: "sha256:identity", + PAPERCLIP_AGENT_PUBLIC_KEY: "public-pem", + PAPERCLIP_AGENT_PRIVATE_KEY: "private-pem", OPENROUTER_API_KEY: "provider-key", PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny", PAPERCLIP_OPENCODE_RUNTIME_DIR: "/runner/opencode", @@ -419,6 +422,9 @@ it("preserves an explicit OpenCode permission mode at the runner spawn boundary" expect(launches).toHaveLength(1); expect(launches[0]!.environment).toMatchObject({ PATH: "/bin", + PAPERCLIP_AGENT_KEY_ID: "sha256:identity", + PAPERCLIP_AGENT_PUBLIC_KEY: "public-pem", + PAPERCLIP_AGENT_PRIVATE_KEY: "private-pem", OPENROUTER_API_KEY: "provider-key", PAPERCLIP_OPENCODE_PERMISSION_MODE: "deny", PAPERCLIP_OPENCODE_RUNTIME_DIR: "/runner/opencode", diff --git a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts index c7f8cc9e876..a61fee55257 100644 --- a/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts +++ b/packages/paperclip-runner/src/control-plane/durable-prp-control-plane.ts @@ -3371,6 +3371,9 @@ const runnerPlatformEnvironmentKeys = [ ] as const; const runnerExplicitProviderEnvironmentKeys = [ + "PAPERCLIP_AGENT_KEY_ID", + "PAPERCLIP_AGENT_PUBLIC_KEY", + "PAPERCLIP_AGENT_PRIVATE_KEY", ...ACPX_CREDENTIAL_NAMES.pi, ...ACPX_CREDENTIAL_NAMES.cursor, ...ACPX_CREDENTIAL_NAMES.copilot, diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.ts b/packages/paperclip-runner/src/drivers/acpx/environment.ts index a78fdcbe268..df705f2d9a6 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.ts @@ -110,6 +110,7 @@ export function createSanitizedAcpxSpawnInput( "RUST_BACKTRACE", "PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", + ...(environment === undefined ? [] : ["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"]), ...credentialNames, ]); let retainedBytes = 0; diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 8ecdbc88ede..b75c21ecf6a 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -453,6 +453,14 @@ export async function prepareAcpxRuntimeSandbox(input: { // also affect provider startup and belongs at the launch boundary. "[features]", "shell_snapshot = false", + ...(input.environment?.PAPERCLIP_AGENT_KEY_ID ? [ + "[shell_environment_policy]", 'inherit = "all"', "ignore_default_excludes = true", + `include_only = ${JSON.stringify([...new Set([ + "PATH", "HOME", "LANG", "LANGUAGE", "TZ", "TMPDIR", "TEMP", "TMP", "CODEX_HOME", + "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", + ...Object.keys(input.environment).filter(key => !/key|secret|token/i.test(key)), + ])])}`, + ] : []), "", ].join("\n"), ); diff --git a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts index 7c86f2bb35c..f6aafd72c08 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts @@ -227,8 +227,10 @@ const SAFE_ENVIRONMENT_KEYS = [ * separate empty-by-default environment and filesystem permission profile. */ export function createSanitizedCodexEnvironment( - source: NodeJS.ProcessEnv = process.env, + source?: NodeJS.ProcessEnv, ): NodeJS.ProcessEnv { + const explicit = source; + source ??= process.env; const environment: NodeJS.ProcessEnv = {}; for (const key of SAFE_ENVIRONMENT_KEYS) { const value = source[key]; @@ -236,6 +238,9 @@ export function createSanitizedCodexEnvironment( if (key.includes("PROXY") && proxyContainsCredentials(value)) continue; environment[key] = value; } + for (const key of ["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"]) { + if (explicit?.[key] !== undefined) environment[key] = explicit[key]; + } Object.assign(environment, githubCredentialEnvironment(source)); return environment; } diff --git a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts index f6480c12c03..3cc702004c2 100644 --- a/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts +++ b/packages/paperclip-runner/src/drivers/codex/codex-security-config.ts @@ -176,7 +176,10 @@ export function createIsolatedCodexAppServerArgs( readOnlyRoots = [...new Set([...readOnlyRoots, ...codexNetworkReadOnlyRoots(source)])]; const networkAccess = codexNetworkAccess(source); const externalRunnerSandbox = usesExternalRunnerSandbox(source); - const inheritedGitHubKeys = githubCredentialEnvironmentKeys(source); + const inheritedGitHubKeys = [ + ...githubCredentialEnvironmentKeys(source), + ...["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"].filter(key => source[key] !== undefined), + ]; const hasProjectedEnvironment = inheritedGitHubKeys.length > 0; // Codex filters the configured `set` values through include_only as well. // Retain the explicit command PATH/HOME/locale settings, not ambient secrets. diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index ab72e2cc4a1..cce75cc2f27 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -2020,7 +2020,11 @@ async function startRuntime(input: { const assignedMcp = nativeMcpLaunchBinding( input.options.environment ?? process.env, ); + const identityKey = input.options.environment?.PAPERCLIP_AGENT_PRIVATE_KEY; + const identityValues = identityKey ? [identityKey, JSON.stringify(identityKey).slice(1, -1), + ...identityKey.split(/\r?\n/).filter(line => line && !line.startsWith("-----"))] : []; input.trace?.addSensitiveValues([ + ...identityValues, password, authHeader, bridge.secret, @@ -2197,6 +2201,7 @@ async function startRuntime(input: { bridge, trace: input.trace, sensitiveValues: [ + ...identityValues, password, input.options.environment?.OPENROUTER_API_KEY, ].filter((value): value is string => Boolean(value)), @@ -2599,6 +2604,7 @@ function sanitizedEnvironment( overrides: Record, ): NodeJS.ProcessEnv { const allowed = [ + "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", "PATH", "LANG", "LC_ALL", diff --git a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts index 68a7c84cc8f..c3ba73df559 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3211,6 +3211,7 @@ export function resolveRunnerdAcpxPermissionMode( } const OPEN_CODE_RUNNER_ENVIRONMENT_KEYS = new Set([ + "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", "PATH", "LANG", "LANGUAGE", @@ -3245,6 +3246,10 @@ function createSanitizedOpenCodeRunnerEnvironment( source: NodeJS.ProcessEnv | undefined, ): NodeJS.ProcessEnv { const candidate = { ...process.env, ...source }; + for (const key of ["PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"]) { + delete candidate[key]; + if (source?.[key] !== undefined) candidate[key] = source[key]; + } return Object.fromEntries( Object.entries(candidate).filter( ([key, value]) => diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 511861df3bd..e20f8785a2e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -933,6 +933,7 @@ export type { AgentDetail, ClearAgentErrorResponse, AgentPermissions, + AgentPublicIdentity, AgentInstructionsBundleMode, AgentInstructionsFileSummary, AgentInstructionsFileDetail, diff --git a/packages/shared/src/types/agent.ts b/packages/shared/src/types/agent.ts index 2cbde14d9c6..2908981ccd8 100644 --- a/packages/shared/src/types/agent.ts +++ b/packages/shared/src/types/agent.ts @@ -415,3 +415,11 @@ export interface AgentInstructionCandidate { createdAt: string; updatedAt: string; } + +/** Public cryptographic identity; private material is runtime-only. */ +export interface AgentPublicIdentity { + algorithm: "Ed25519"; + keyId: string; + publicKeyPem: string; + createdAt: string; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 6d899e083ee..449dbc58097 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -280,6 +280,7 @@ export type { AgentDetail, ClearAgentErrorResponse, AgentPermissions, + AgentPublicIdentity, AgentRuntimeConfig, AgentInstructionsBundleMode, AgentInstructionsFileSummary, diff --git a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts index 9571e69b133..c7c1af49314 100644 --- a/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts +++ b/server/src/__tests__/agent-cross-tenant-authz-routes.test.ts @@ -357,6 +357,12 @@ describe("agent cross-tenant route authorization", () => { isInstanceAdmin: false, }; const deniedCases = [ + { + label: "public cryptographic identity", + request: (app: express.Express) => + requestApp(app, (baseUrl) => request(baseUrl).get(`/api/agents/${agentId}/identity`)), + untouched: [], + }, { label: "pause", request: (app: express.Express) => diff --git a/server/src/__tests__/agent-identity-master-key.test.ts b/server/src/__tests__/agent-identity-master-key.test.ts new file mode 100644 index 00000000000..98029e89968 --- /dev/null +++ b/server/src/__tests__/agent-identity-master-key.test.ts @@ -0,0 +1,36 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; +import { expect, it, vi } from "vitest"; +import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js"; + +it("atomically publishes one complete master key across competing processes", async () => { + const directory = await mkdtemp(join(tmpdir(), "identity-master-race-")); + const keyFile = join(directory, "master.key"); + const providerPath = fileURLToPath(new URL("../secrets/local-encrypted-provider.ts", import.meta.url)); + const tsx = createRequire(import.meta.url).resolve("tsx/esm"); + try { + const script = `import {localEncryptedProvider as p} from ${JSON.stringify(providerPath)}; console.log(JSON.stringify(await p.createSecret({value:'race-challenge'})));`; + const children = await Promise.all(Array.from({ length: 6 }, () => promisify(execFile)(process.execPath, + ["--import", tsx, "--input-type=module", "-e", script], { + env: { ...process.env, PAPERCLIP_SECRETS_MASTER_KEY: "", PAPERCLIP_SECRETS_MASTER_KEY_FILE: keyFile }, + }))); + expect(Buffer.from(await readFile(keyFile, "utf8"), "base64")).toHaveLength(32); + expect((await stat(keyFile)).mode & 0o777).toBe(0o600); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", ""); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", keyFile); + for (const child of children) { + expect(await localEncryptedProvider.resolveVersion(JSON.parse(child.stdout))).toBe("race-challenge"); + } + await rm(keyFile); + await expect(localEncryptedProvider.resolveVersion(JSON.parse(children[0].stdout))).rejects.toThrow("master key file is missing"); + await expect(stat(keyFile)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + vi.unstubAllEnvs(); + await rm(directory, { recursive: true, force: true }); + } +}, 30_000); diff --git a/server/src/__tests__/agent-identity.test.ts b/server/src/__tests__/agent-identity.test.ts new file mode 100644 index 00000000000..1c52eb545d8 --- /dev/null +++ b/server/src/__tests__/agent-identity.test.ts @@ -0,0 +1,223 @@ +import { randomBytes, randomUUID, verify, generateKeyPairSync } from "node:crypto"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import express from "express"; +import request from "supertest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { agentIdentityKeys, agents, companies, createDb, closeRegisteredClients, ensurePostgresDatabase, runDatabaseBackup, runDatabaseRestore, type Db } from "@paperclipai/db"; +import { resolveWorktreeSeedPlan } from "../../../cli/src/commands/worktree-lib.js"; +import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { agentIdentityService, supportsManagedAgentIdentity } from "../services/agent-identity.js"; +import { agentService } from "../services/agents.js"; +import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js"; +import { execute } from "../adapters/process/execute.js"; +import { createAgentIdentityRedactor } from "../services/agent-identity-redaction.js"; +import { heartbeatService } from "../services/heartbeat.js"; +import { agentRoutes } from "../routes/agents.js"; +import { actorMiddleware } from "../middleware/auth.js"; +import { errorHandler } from "../middleware/error-handler.js"; +import { drainHeartbeatRunsToQuiescence } from "./helpers/drain-heartbeat-runs.js"; + +describe("agent cryptographic identity", () => { + let db: Db; + let database: Awaited>; + let directory: string; + let companyId: string; + + beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), "paperclip-identity-")); + vi.stubEnv("PAPERCLIP_HOME", directory); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", ""); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY_FILE", join(directory, "master.key")); + database = await startEmbeddedPostgresTestDatabase("paperclip-identity-"); + db = createDb(database.connectionString); + companyId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Identity test", issuePrefix: "KEY", defaultResponsibleUserId: "identity-test-owner" }); + }); + afterAll(async () => { + await database?.cleanup(); + vi.unstubAllEnvs(); + await rm(directory, { recursive: true, force: true }); + }); + + async function oldAgent() { + const [agent] = await db.insert(agents).values({ companyId, name: randomUUID(), role: "engineer" }).returning(); + return agent; + } + + it("migration and public reads leave existing agents unprovisioned", async () => { + const agent = await oldAgent(); + expect(await agentIdentityService(db).getPublicIdentity(companyId, agent.id)).toBeNull(); + expect(await db.select().from(agentIdentityKeys)).toHaveLength(0); + for (const type of ["http", "openclaw_gateway", "hermes_gateway"]) expect(supportsManagedAgentIdentity(type)).toBe(false); + for (const provider of ["claude_managed", "aws_agentcore"]) expect(supportsManagedAgentIdentity("paperclip_runner", { provider })).toBe(false); + expect(supportsManagedAgentIdentity("paperclip_runner", { provider: "codex" }, "claude_managed_agents_api")).toBe(false); + expect(supportsManagedAgentIdentity("paperclip_runner", { provider: "claude_managed" }, "codex_app_server")).toBe(true); + }); + + it("concurrent first runs converge, restart/model/name changes preserve keys, and ownership is enforced", async () => { + const agent = await oldAgent(); + const pairs = await Promise.all(Array.from({ length: 8 }, () => + agentIdentityService(db).ensureAgentIdentity(companyId, agent.id))); + expect(new Set(pairs.map(pair => pair.privateKeyPem)).size).toBe(1); + await db.update(agents).set({ name: "Renamed", adapterConfig: { model: "another-model" }, status: "paused" }).where(eq(agents.id, agent.id)); + const freshDb = createDb(database.connectionString); + expect(await agentIdentityService(freshDb).ensureAgentIdentity(companyId, agent.id)).toEqual(pairs[0]); + expect(await agentIdentityService(db).getPublicIdentity(randomUUID(), agent.id)).toBeNull(); + await expect(agentIdentityService(db).ensureAgentIdentity(randomUUID(), agent.id)).rejects.toThrow("Agent not found"); + const second = await oldAgent(); + expect((await agentIdentityService(db).ensureAgentIdentity(companyId, second.id)).keyId).not.toBe(pairs[0].keyId); + await db.delete(agents).where(eq(agents.id, second.id)); + expect(await agentIdentityService(db).getPublicIdentity(companyId, second.id)).toBeNull(); + }); + + it("creates identities in the agent creation transaction and rolls both writes back on failure", async () => { + const created = await agentService(db).create(companyId, { name: "New identity", role: "engineer", status: "pending_approval" }); + expect(await agentIdentityService(db).getPublicIdentity(companyId, created.id)).toMatchObject({ algorithm: "Ed25519" }); + const id = randomUUID(); + await expect(db.transaction(async tx => { + await tx.insert(agents).values({ id, companyId, name: "Rollback", role: "engineer" }); + await agentIdentityService(tx as unknown as Db).ensureAgentIdentity(companyId, id); + throw new Error("rollback"); + })).rejects.toThrow("rollback"); + expect(await db.select().from(agents).where(eq(agents.id, id))).toHaveLength(0); + expect(await agentIdentityService(db).getPublicIdentity(companyId, id)).toBeNull(); + const fail = vi.spyOn(localEncryptedProvider, "createSecret").mockRejectedValueOnce(new Error("encryption unavailable")); + await expect(agentService(db).create(companyId, { name: "Must roll back", role: "engineer", status: "pending_approval" })).rejects.toThrow("encryption unavailable"); + fail.mockRestore(); + expect(await db.select().from(agents).where(eq(agents.name, "Must roll back"))).toHaveLength(0); + }); + + it("fails closed for wrong master keys, corrupted material and mismatched public keys", async () => { + const agent = await oldAgent(); + const identity = await agentIdentityService(db).ensureAgentIdentity(companyId, agent.id); + const [stored] = await db.select().from(agentIdentityKeys).where(eq(agentIdentityKeys.agentId, agent.id)); + expect(JSON.stringify(stored)).not.toContain(identity.privateKeyPem.split("\n")[1]); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", randomBytes(32).toString("base64")); + await expect(agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)).rejects.toThrow("decryption failed"); + expect(await agentIdentityService(db).getPublicIdentity(companyId, agent.id)).toMatchObject({ keyId: identity.keyId }); + vi.stubEnv("PAPERCLIP_SECRETS_MASTER_KEY", ""); + await db.update(agentIdentityKeys).set({ privateKeyMaterial: { ...stored.privateKeyMaterial, ciphertext: "AAAA" } }).where(eq(agentIdentityKeys.agentId, agent.id)); + await expect(agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)).rejects.toThrow(); + const other = generateKeyPairSync("ed25519").publicKey.export({ type: "spki", format: "pem" }).toString(); + await db.update(agentIdentityKeys).set({ privateKeyMaterial: stored.privateKeyMaterial, publicKeyPem: other }).where(eq(agentIdentityKeys.agentId, agent.id)); + await expect(agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)).rejects.toThrow("do not match"); + expect((await db.select().from(agentIdentityKeys).where(eq(agentIdentityKeys.agentId, agent.id)))[0].keyId).toBe(identity.keyId); + }); + + it("a real managed process signs a random challenge before and after reconnect, ignoring hostile env overrides", async () => { + const agent = await oldAgent(); + const challenge = randomBytes(32).toString("base64"); + let publicPem = ""; + for (let restart = 0; restart < 2; restart++) { + const restarted = agentIdentityService(createDb(database.connectionString)); + const identity = await restarted.ensureAgentIdentity(companyId, agent.id); + const publicIdentity = await restarted.getPublicIdentity(companyId, agent.id); + if (restart) expect(publicIdentity!.publicKeyPem).toBe(publicPem); + publicPem = publicIdentity!.publicKeyPem; + vi.stubEnv("PAPERCLIP_AGENT_PRIVATE_KEY", "inherited override"); + const logs: string[] = []; + const metas: unknown[] = []; + await execute({ + runId: randomUUID(), agent, agentIdentity: identity, + config: { command: process.execPath, cwd: directory, args: ["-e", `const {sign}=require('node:crypto'); process.stdout.write(sign(null,Buffer.from(process.env.CHALLENGE,'base64'),process.env.PAPERCLIP_AGENT_PRIVATE_KEY).toString('base64'));`], env: { CHALLENGE: challenge, PAPERCLIP_AGENT_PRIVATE_KEY: "configured override" } }, + context: {}, runtime: { sessionId: null, sessionParams: null, taskKey: null }, + onLog: async (_stream, chunk) => { logs.push(chunk); }, onMeta: async meta => { metas.push(meta); }, + }); + expect(verify(null, Buffer.from(challenge, "base64"), publicPem, Buffer.from(logs.join(""), "base64"))).toBe(true); + expect(JSON.stringify(metas)).not.toContain(identity.privateKeyPem.split("\n")[1]); + } + }); + + it("lazily provisions through managed run preparation, exposes only the public API key, and redacts emitted keys", async () => { + const agent = await oldAgent(); + const app = express(); + app.use(actorMiddleware(db, { deploymentMode: "local_trusted" })); + app.use("/api", agentRoutes(db)); + app.use(errorHandler); + const firstRead = await request(app).get(`/api/agents/${agent.id}/identity`); + expect(firstRead.status).toBe(200); + expect(firstRead.body).toBeNull(); + const challenge = randomBytes(32).toString("base64"); + await db.update(agents).set({ adapterType: "process", adapterConfig: { + command: process.execPath, cwd: directory, + args: ["-e", `const {sign}=require('node:crypto'); const k=process.env.PAPERCLIP_AGENT_PRIVATE_KEY; console.log('SIGNATURE:'+sign(null,Buffer.from(process.env.CHALLENGE,'base64'),k).toString('base64')); process.stdout.write(k.slice(0,40)); setTimeout(()=>process.stdout.write(k.slice(40)),30);`], + env: { CHALLENGE: challenge, PAPERCLIP_AGENT_PRIVATE_KEY: "ignored override" }, + } }).where(eq(agents.id, agent.id)); + let previousKeyId = ""; + for (let restart = 0; restart < 2; restart++) { + const heartbeat = heartbeatService(createDb(database.connectionString)); + try { + const run = await heartbeat.invoke(agent.id, "on_demand", {}, "manual"); + expect(run).toBeTruthy(); + await drainHeartbeatRunsToQuiescence(db, heartbeat); + const finished = await heartbeat.getRun(run!.id); + expect(finished?.status, JSON.stringify(finished)).toBe("succeeded"); + const publicResponse = await request(app).get(`/api/agents/${agent.id}/identity`); + expect(publicResponse.status).toBe(200); + expect(Object.keys(publicResponse.body).sort()).toEqual(["algorithm", "createdAt", "keyId", "publicKeyPem"]); + if (restart) expect(publicResponse.body.keyId).toBe(previousKeyId); + previousKeyId = publicResponse.body.keyId; + const signature = /SIGNATURE:([A-Za-z0-9+/=]+)/.exec(finished!.stdoutExcerpt ?? "")?.[1]; + expect(signature).toBeTruthy(); + expect(verify(null, Buffer.from(challenge, "base64"), publicResponse.body.publicKeyPem, Buffer.from(signature!, "base64"))).toBe(true); + const privateBody = (await agentIdentityService(db).ensureAgentIdentity(companyId, agent.id)).privateKeyPem.split("\n")[1]; + expect(JSON.stringify(finished)).not.toContain(privateBody); + expect(JSON.stringify(await heartbeat.readLog(run!.id))).not.toContain(privateBody); + } finally { + await drainHeartbeatRunsToQuiescence(db, heartbeat); + } + } + }, 60_000); + + it("disaster recovery retains identity; both development seed modes omit it and mint fresh keys", async () => { + const agent = await oldAgent(); + const original = await agentIdentityService(db).ensureAgentIdentity(companyId, agent.id); + for (const mode of ["backup", "minimal", "full"] as const) { + const seed = mode === "backup" ? undefined : resolveWorktreeSeedPlan(mode); + const backup = await runDatabaseBackup({ + connectionString: database.connectionString, backupDir: join(directory, mode), + backupEngine: "javascript", retention: { dailyDays: 1, weeklyWeeks: 0, monthlyMonths: 0 }, + excludeTables: seed?.excludedTables, nullifyColumns: seed?.nullifyColumns, + }); + const target = new URL(database.connectionString); + const admin = new URL(database.connectionString); + admin.pathname = "/postgres"; + await ensurePostgresDatabase(admin.toString(), `identity_${mode}`); + target.pathname = `/identity_${mode}`; + await runDatabaseRestore({ connectionString: target.toString(), backupFile: backup.backupFile }); + const restored = agentIdentityService(createDb(target.toString())); + if (mode === "backup") expect(await restored.ensureAgentIdentity(companyId, agent.id)).toEqual(original); + else { + expect(await restored.getPublicIdentity(companyId, agent.id)).toBeNull(); + expect((await restored.ensureAgentIdentity(companyId, agent.id)).keyId).not.toBe(original.keyId); + } + await closeRegisteredClients(target.toString()); + } + }, 60_000); +}); + +describe("identity output redaction", () => { + const pem = generateKeyPairSync("ed25519").privateKey.export({ format: "pem", type: "pkcs8" }).toString(); + it("redacts PEM, JSON-escaped PEM and raw private material", () => { + const redactor = createAgentIdentityRedactor(pem); + for (const text of [pem, JSON.stringify(pem), pem.split("\n")[1]]) { + expect(redactor.redact({ output: text }).output).not.toContain(pem.split("\n")[1]); + } + }); + it("redacts every split boundary and interleaved streams, including interrupted prefixes", () => { + for (let split = 1; split < pem.length; split++) { + const redactor = createAgentIdentityRedactor(pem); + let output = redactor.chunk("stdout", pem.slice(0, split)); + expect(redactor.chunk("stderr", "ordinary output\n")).toBe("ordinary output\n"); + output += redactor.chunk("stdout", pem.slice(split)); + output += redactor.finish("stdout"); + expect(output).not.toContain(pem.split("\n")[1]); + } + const redactor = createAgentIdentityRedactor(pem); + expect(redactor.chunk("stdout", pem.slice(0, 42))).toBe(""); + expect(redactor.finish("stdout")).toBe("***REDACTED***"); + }); +}); diff --git a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts index 211c9e662cd..781937c13ce 100644 --- a/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts +++ b/server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts @@ -1008,7 +1008,7 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => { expect(adapterInput.context.paperclipTaskMarkdown).not.toContain("Create child issues from the approved plan only"); }, 20_000); - it("preserves accepted-plan continuation resume state when the wake issue owns the in-flight claim", async () => { + it("preserves accepted-plan instructions but replaces a pre-identity session when the wake issue owns the in-flight claim", async () => { const companyId = randomUUID(); const projectId = randomUUID(); const projectWorkspaceId = randomUUID(); @@ -1138,7 +1138,8 @@ describeEmbeddedPostgres("accepted plan workspace refresh", () => { runtime: { sessionId: string | null; sessionParams: Record | null }; context: Record; }; - expect(adapterInput.runtime.sessionId).toBe("accepted-plan-retry-session"); + // The old process predates identity injection and must be replaced. + expect(adapterInput.runtime.sessionId).toBeNull(); expect(adapterInput.context.acceptedPlanWakeRouting).toBeUndefined(); expect(adapterInput.context.paperclipTaskMarkdown).toContain( "Implement the accepted plan on this issue when the work is small and cohesive.", diff --git a/server/src/adapters/process/execute.ts b/server/src/adapters/process/execute.ts index 0f08381574f..672a4827ab8 100644 --- a/server/src/adapters/process/execute.ts +++ b/server/src/adapters/process/execute.ts @@ -26,7 +26,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise = { - ...buildPaperclipEnv(agent), + ...buildPaperclipEnv(agent, ctx.agentIdentity), ...buildRuntimeToolsEnv(ctx.runtimeTools), }; for (const [k, v] of Object.entries(envConfig)) { diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 0f7e74bbee2..8cb235af1d1 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,3 +1,4 @@ +import { agentIdentityService } from "../services/agent-identity.js"; import { aiConnectionRouterService, poolMemberRuntimeConfig } from "../services/ai-connection-router.js"; import { connectionIntentService } from "../services/connection-intents.js"; import { completeConnectionIntentSchema } from "@paperclipai/shared"; @@ -4385,6 +4386,12 @@ export function agentRoutes( res.json(rows); }); + router.get("/agents/:id/identity", async (req, res) => { + const agent = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Agent not found"); + if (!agent || !(await assertAgentReadAllowed(req, res, agent))) return; + res.json(await agentIdentityService(db).getPublicIdentity(agent.companyId, agent.id)); + }); + router.get("/agents/:id", async (req, res) => { const id = req.params.id as string; const agent = await getAccessibleResource(req, res, svc.getById(id), "Agent not found"); diff --git a/server/src/secrets/local-encrypted-provider.ts b/server/src/secrets/local-encrypted-provider.ts index 33d3c0c17c4..ba7daa5bc18 100644 --- a/server/src/secrets/local-encrypted-provider.ts +++ b/server/src/secrets/local-encrypted-provider.ts @@ -1,5 +1,5 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto"; -import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, linkSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; import path from "node:path"; import { resolveDefaultSecretsKeyFilePath } from "../home-paths.js"; import type { @@ -45,7 +45,7 @@ function decodeMasterKey(raw: string): Buffer | null { return null; } -function loadOrCreateMasterKey(): Buffer { +function loadOrCreateMasterKey(allowCreate = true): Buffer { const envKeyRaw = process.env.PAPERCLIP_SECRETS_MASTER_KEY; if (envKeyRaw && envKeyRaw.trim().length > 0) { const fromEnv = decodeMasterKey(envKeyRaw); @@ -68,16 +68,24 @@ function loadOrCreateMasterKey(): Buffer { return decoded; } + if (!allowCreate) throw new Error(`Secrets master key file is missing: ${keyPath}`); const dir = path.dirname(keyPath); mkdirSync(dir, { recursive: true }); const generated = randomBytes(32); - writeFileSync(keyPath, generated.toString("base64"), { encoding: "utf8", mode: 0o600 }); + const temporaryPath = `${keyPath}.${process.pid}.${randomBytes(12).toString("hex")}.tmp`; + writeFileSync(temporaryPath, generated.toString("base64"), { encoding: "utf8", mode: 0o600, flag: "wx" }); try { - chmodSync(keyPath, 0o600); - } catch { - // best effort + // A hard link publishes a complete file without overwriting a competitor's + // key. Opening the destination with wx would expose an empty/partial file. + try { + linkSync(temporaryPath, keyPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err; + } + } finally { + unlinkSync(temporaryPath); } - return generated; + return loadOrCreateMasterKey(false); } function enforceKeyFilePermissionsBestEffort(keyPath: string) { @@ -272,7 +280,7 @@ export const localEncryptedProvider: SecretProviderModule = { throw badRequest("local_encrypted does not support external reference secrets"); }, async resolveVersion(input) { - const masterKey = loadOrCreateMasterKey(); + const masterKey = loadOrCreateMasterKey(false); return decryptValue(masterKey, asLocalEncryptedMaterial(input.material)); }, async deleteOrArchive() { diff --git a/server/src/services/agent-identity-redaction.ts b/server/src/services/agent-identity-redaction.ts new file mode 100644 index 00000000000..35b581dc4c5 --- /dev/null +++ b/server/src/services/agent-identity-redaction.ts @@ -0,0 +1,37 @@ +import { REDACTED_EVENT_VALUE } from "../redaction.js"; +import { redactRegisteredSecretValues } from "./run-secret-redaction.js"; + +/** Keep private material in memory only; do not copy it into run snapshots. */ +export function createAgentIdentityRedactor(privateKeyPem?: string) { + const values = privateKeyPem ? [...new Set([ + privateKeyPem, + privateKeyPem.trim(), + JSON.stringify(privateKeyPem).slice(1, -1), + ...privateKeyPem.split(/\r?\n/).filter(line => line && !line.startsWith("-----")), + ])].sort((a, b) => b.length - a.length) : []; + const pending = new Map(); + return { + values, + redact(value: T): T { return redactRegisteredSecretValues(value, values); }, + // Hold only a suffix that could begin a secret. Separate buffers prevent + // interleaved stdout/stderr from defeating matching across output chunks. + chunk(stream: string, chunk: string): string { + let text = (pending.get(stream) ?? "") + chunk; + for (const value of values) text = text.split(value).join(REDACTED_EVENT_VALUE); + let held = 0; + for (const value of values) { + for (let size = Math.min(value.length - 1, text.length); size > held; size--) { + if (text.endsWith(value.slice(0, size))) { held = size; break; } + } + } + pending.set(stream, held ? text.slice(-held) : ""); + return held ? text.slice(0, -held) : text; + }, + // An interrupted partial secret must not be flushed as plaintext. + finish(stream: string): string { + const held = pending.get(stream); + pending.delete(stream); + return held ? REDACTED_EVENT_VALUE : ""; + }, + }; +} diff --git a/server/src/services/agent-identity.ts b/server/src/services/agent-identity.ts new file mode 100644 index 00000000000..462c0393931 --- /dev/null +++ b/server/src/services/agent-identity.ts @@ -0,0 +1,88 @@ +import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { agentIdentityKeys, agents, type Db } from "@paperclipai/db"; +import type { AgentPublicIdentity } from "@paperclipai/shared"; +import { notFound } from "../errors.js"; +import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js"; +import { persistActivity } from "./activity-log.js"; + +const publicColumns = { + algorithm: agentIdentityKeys.algorithm, + keyId: agentIdentityKeys.keyId, + publicKeyPem: agentIdentityKeys.publicKeyPem, + createdAt: agentIdentityKeys.createdAt, +}; + +export function agentIdentityService(db: Db) { + const ownership = (companyId: string, agentId: string) => and( + eq(agentIdentityKeys.companyId, companyId), eq(agentIdentityKeys.agentId, agentId), + ); + + return { + // Reading an identity must never provision it or decrypt private material. + async getPublicIdentity(companyId: string, agentId: string): Promise { + const [row] = await db.select(publicColumns).from(agentIdentityKeys).where(ownership(companyId, agentId)); + return row ? { ...row, createdAt: row.createdAt.toISOString() } : null; + }, + + async ensureAgentIdentity(companyId: string, agentId: string) { + return db.transaction(async (tx) => { + // Lock the parent even when there is no identity row yet. This also + // serializes deletion and works inside the agent-creation transaction. + const [owner] = await tx.select({ id: agents.id }).from(agents) + .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId))).for("update"); + if (!owner) throw notFound("Agent not found"); + let [row] = await tx.select().from(agentIdentityKeys).where(ownership(companyId, agentId)); + if (!row) { + const keys = generateKeyPairSync("ed25519"); + const publicKeyPem = keys.publicKey.export({ type: "spki", format: "pem" }).toString(); + const keyId = fingerprint(keys.publicKey.export({ type: "spki", format: "der" })); + const privateKeyPem = keys.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const encrypted = await localEncryptedProvider.createSecret({ value: privateKeyPem }); + [row] = await tx.insert(agentIdentityKeys).values({ + companyId, agentId, algorithm: "Ed25519", keyId, publicKeyPem, + privateKeyMaterial: encrypted.material, + }).returning(); + await persistActivity(tx as unknown as Db, { + companyId, actorType: "system", actorId: "system", + action: "agent.identity.created", entityType: "agent", entityId: agentId, + details: { algorithm: "Ed25519", keyId }, + }); + } + const privateKeyPem = await localEncryptedProvider.resolveVersion({ + material: row.privateKeyMaterial, externalRef: null, + }); + // Fail closed on corruption or a wrong master key; never replace a key. + const privateKey = createPrivateKey(privateKeyPem); + const derived = createPublicKey(privateKey).export({ type: "spki", format: "der" }); + const stored = createPublicKey(row.publicKeyPem).export({ type: "spki", format: "der" }); + if (row.algorithm !== "Ed25519" || privateKey.asymmetricKeyType !== "ed25519" || + !derived.equals(stored) || fingerprint(stored) !== row.keyId) { + throw new Error("Agent identity public and private keys do not match"); + } + return { keyId: row.keyId, publicKeyPem: row.publicKeyPem, privateKeyPem }; + }); + }, + }; +} + +function fingerprint(spki: Buffer): string { + return `sha256:${createHash("sha256").update(spki).digest("base64url")}`; +} + +export function supportsManagedAgentIdentity( + adapterType: string, + config?: { provider?: unknown }, + persistedDriver?: string | null, +): boolean { + if (adapterType === "paperclip_runner") { + // API-hosted providers have no process environment under our control. + return persistedDriver + ? ["codex", "codex_app_server", "opencode_server", "acpx_runtime"].includes(persistedDriver) + : [undefined, "codex", "opencode", "acpx"].includes(config?.provider as string | undefined); + } + return new Set([ + "process", "claude_local", "codex_local", "cursor_cloud", + "gemini_local", "grok_local", "hermes_local", "kimi_local", "opencode_local", "pi_local", "cursor", + ]).has(adapterType); +} diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 13450be91f0..eb1bdacefcd 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -58,6 +58,8 @@ import { } from "./built-in-agent-metadata.js"; import { issueThreadInteractionService } from "./issue-thread-interactions.js"; +import { agentIdentityService } from "./agent-identity.js"; + function hashToken(token: string) { return createHash("sha256").update(token).digest("hex"); } @@ -945,6 +947,7 @@ export function agentService(db: Db) { }) .returning() .then((rows) => rows[0]); + await agentIdentityService(txDb).ensureAgentIdentity(companyId, created.id); // New standard agents receive the standard direct grants at activation. // Low-trust and bundled agents keep their explicit, narrower grants. if (created.status !== "pending_approval" && !permissionsImplyLowTrust(normalizedPermissions) && diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 8d512560003..43c7295da77 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1,3 +1,6 @@ +import { createAgentIdentityRedactor } from "./agent-identity-redaction.js"; +import { agentIdentityService, supportsManagedAgentIdentity } from "./agent-identity.js"; +import { buildAgentIdentityEnv } from "@paperclipai/adapter-utils/server-utils"; import { retryIdempotentDatabaseOperation } from "../database-retry.js"; import { prepareConnectionInstructionDelivery } from "./connection-instructions.js"; import { resolveAssignedConnectionInstructionsForRun } from "./native-runtime/assigned-mcp-tools.js"; @@ -1487,6 +1490,7 @@ const LOW_TRUST_SENSITIVE_ENV_KEY_RE = // 3. Any other PAPERCLIP_*-named binding is user data and flows through to // the run env like any non-prefixed binding. const FORBIDDEN_ENV_BINDING_KEYS = new Set([ + "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", "PAPERCLIP_RUNNER_NETWORK_ACCESS", "PAPERCLIP_RUNNER_NETWORK_ROOTS", "PAPERCLIP_API_KEY", @@ -1514,7 +1518,7 @@ function stripForbiddenEnvBindings( const filtered = Object.fromEntries( Object.entries(record).filter( ([key]) => - !FORBIDDEN_ENV_BINDING_KEYS.has(key) && + !FORBIDDEN_ENV_BINDING_KEYS.has(key.toUpperCase()) && !(managedGitHubCredentials && MANAGED_GITHUB_TOKEN_KEYS.has(key)), ), ); @@ -6579,6 +6583,7 @@ function buildSessionConfigCategoryValues(input: { secretManifest: readonly EffectiveRunConfigSecretManifestEntry[]; runtimeSkills: unknown; agentConfigRevision: unknown; + agentIdentityKeyId?: string; }) { const sanitizedSecretManifest = sanitizeSecretManifestForConfigFingerprint( input.secretManifest, @@ -6600,6 +6605,7 @@ function buildSessionConfigCategoryValues(input: { return { adapter: { adapterType: input.adapterType, + ...(input.agentIdentityKeyId ? { agentIdentityKeyId: input.agentIdentityKeyId } : {}), agentConfigRevision: input.agentConfigRevision, }, adapterConfig: input.effectiveAdapterConfig, @@ -6632,6 +6638,7 @@ export async function buildEffectiveRunSessionConfigMetadata(input: { secretManifest?: readonly EffectiveRunConfigSecretManifestEntry[]; runtimeSkills: unknown; agentConfigRevision?: unknown; + agentIdentityKeyId?: string; }): Promise { const secretManifest = input.secretManifest ?? []; const instructions = await resolveInstructionsConfigFingerprintMetadata( @@ -6651,6 +6658,7 @@ export async function buildEffectiveRunSessionConfigMetadata(input: { secretManifest, runtimeSkills: input.runtimeSkills, agentConfigRevision: input.agentConfigRevision ?? null, + agentIdentityKeyId: input.agentIdentityKeyId, }); const fingerprints = createEffectiveRunConfigFingerprints({ session: categoryValues, @@ -20532,6 +20540,7 @@ export function heartbeatService( > | null = null; let providerTraceFinalized = false; let readFailureReportSecrets: () => string[] = () => []; + let identityRedactor = createAgentIdentityRedactor(); try { let agent = await getAgent(run.agentId); @@ -21870,9 +21879,15 @@ export function heartbeatService( resolve: () => resolveAssignedConnectionInstructionsForRun(db, { companyId: agent.companyId, agentId: agent.id, runId: run.id }), context, config: connectorDelivery.config, native: agent.adapterType === "paperclip_runner", }); + const agentIdentity = supportsManagedAgentIdentity(agent.adapterType, agent.adapterConfig, run.runtimeMode === "native" ? run.driverKind : undefined) + ? await agentIdentityService(db).ensureAgentIdentity(agent.companyId, agent.id) + : undefined; + identityRedactor = createAgentIdentityRedactor(agentIdentity?.privateKeyPem); + if (agentIdentity) secretKeys.add("PAPERCLIP_AGENT_PRIVATE_KEY"); const resolvedFailureSecrets = readFailureReportSecrets(); readFailureReportSecrets = () => [ ...resolvedFailureSecrets, + ...identityRedactor.values, ...collectRunFailureSecretValues(runtimeConfig.env, secretKeys), ]; const latestAgentConfigRevision = await getLatestAgentConfigRevision( @@ -21880,6 +21895,7 @@ export function heartbeatService( agent.id, ); const sessionConfigMetadataInput = { + agentIdentityKeyId: agentIdentity?.keyId, adapterType: agent.adapterType, effectiveAdapterConfig: runtimeConfig, managedAiHome: managedAiRuntime?.home, @@ -21978,7 +21994,7 @@ export function heartbeatService( compatibleConfigMetadata, wakeResetReason: wakeSessionResetReason, preserveLegacySessionWithoutConfigMetadata: - acceptedPlanContinuationWake && !acceptedPlanWakeRoutingDecision, + acceptedPlanContinuationWake && !acceptedPlanWakeRoutingDecision && !agentIdentity, }); const resetTaskSession = shouldResetTaskSessionForWake(context) || sessionConfigFreshness.reset; @@ -23432,7 +23448,7 @@ export function heartbeatService( await getCurrentUserRedactionOptions(); const onLog = async (stream: "stdout" | "stderr", chunk: string) => { const sanitizedChunk = compactRunLogChunk( - redactCurrentUserText(chunk, currentUserRedactionOptions), + redactCurrentUserText(identityRedactor.chunk(stream, chunk), currentUserRedactionOptions), ); if (stream === "stdout") stdoutExcerpt = appendExcerpt(stdoutExcerpt, sanitizedChunk); @@ -23593,6 +23609,7 @@ export function heartbeatService( } } const onAdapterMeta = async (meta: AdapterInvocationMeta) => { + meta = identityRedactor.redact(meta); if (meta.env && secretKeys.size > 0) { for (const key of secretKeys) { if (key in meta.env) meta.env[key] = "***REDACTED***"; @@ -23608,6 +23625,7 @@ export function heartbeatService( }; const onAdapterEvent = async (event: AdapterRuntimeEvent) => { + event = identityRedactor.redact(event); const eventType = event.eventType.trim(); if (!eventType) return; await appendRunEvent(currentRun, { @@ -24176,6 +24194,7 @@ export function heartbeatService( getNativeFreshSessionHandoff = nativeReviewRequest ? undefined : getFreshSessionHandoff; const buildExecution = ({ normalizedSessionId, resumedSession }: { normalizedSessionId: string; resumedSession: boolean }) => buildNativeExecutionInput({ + agentKeyId: agentIdentity?.keyId, companyId: agent.companyId, runId: run.id, issue: nativeReviewRequest ? { ...issueRef, title: `Review: ${issueRef.title}`, description: nativeReviewRequest } : issueRef, @@ -24909,6 +24928,7 @@ export function heartbeatService( process.env, executionWorkspace.cwd, ), + ...buildAgentIdentityEnv(agentIdentity), ...(instructionCopy && isAgentDirectoryCopy(instructionCopy) ? { AGENT_HOME: instructionCopy.executionRoot } : {}), ...(nativeMcpServer ? { @@ -25065,6 +25085,7 @@ export function heartbeatService( legacyAdapterEntered = true; return withAdapterExecutionPhase(executionPhaseContext, "adapter_execution", () => adapter.execute({ getFreshSessionHandoff, + agentIdentity, runId: run.id, agent, runtime: runtimeForAdapter, @@ -25145,6 +25166,11 @@ export function heartbeatService( if (!guardedDispatch.dispatched) return; adapterResult = await guardedDispatch.resultPromise; } + adapterResult = identityRedactor.redact(adapterResult); + for (const stream of ["stdout", "stderr"] as const) { + const tail = identityRedactor.finish(stream); + if (tail) await onLog(stream, tail); + } if (instructionSave) adapterResult.resultJson = { ...adapterResult.resultJson, instructionSave }; adapterResult = applyWorkspaceRestoreFailure(adapterResult); // A returned result can include a failed restore. Keep the workspace @@ -26223,7 +26249,7 @@ export function heartbeatService( // child. Let the cancellation write settle before attempting failure. await processRunCancellationSettlements.get(run.id)?.settled; const message = redactCurrentUserText( - err instanceof Error ? err.message : "Unknown adapter failure", + identityRedactor.redact(err instanceof Error ? err.message : "Unknown adapter failure"), await getCurrentUserRedactionOptions(), ); const workspaceValidationFailure = isWorkspaceValidationFailure(err) @@ -26266,7 +26292,7 @@ export function heartbeatService( recordedResponsibleUserDenialCode ?? nativeTerminalFailureCode ?? "adapter_failed"; - logger.error({ err, runId }, "heartbeat execution failed"); + logger.error({ err: identityRedactor.redact({ message: err instanceof Error ? err.message : String(err), stack: err instanceof Error ? err.stack : undefined }), runId }, "heartbeat execution failed"); let logSummary: { bytes: number | null; @@ -26480,9 +26506,9 @@ export function heartbeatService( // Setup code before adapter.execute threw (e.g. ensureRuntimeState, resolveWorkspaceForRun). // The inner catch did not fire, so we must record the failure here. const message = redactCurrentUserText( - outerErr instanceof Error + identityRedactor.redact(outerErr instanceof Error ? outerErr.message - : "Unknown setup failure", + : "Unknown setup failure"), await getCurrentUserRedactionOptions(), ); // A missing secret/env binding is a known pre-dispatch configuration gap, @@ -26526,7 +26552,7 @@ export function heartbeatService( nonRetryablePreflightCode ?? "setup_failed"; logger.error( - { err: outerErr, runId }, + { err: identityRedactor.redact({ message: outerErr instanceof Error ? outerErr.message : String(outerErr), stack: outerErr instanceof Error ? outerErr.stack : undefined }), runId }, "heartbeat execution setup failed", ); const setupFailureAgent = await getAgent(run.agentId).catch(() => null); diff --git a/server/src/services/native-runtime/native-execution-input.ts b/server/src/services/native-runtime/native-execution-input.ts index 839b52ccc4b..2626701c11a 100644 --- a/server/src/services/native-runtime/native-execution-input.ts +++ b/server/src/services/native-runtime/native-execution-input.ts @@ -57,6 +57,7 @@ export function buildNativeExecutionInput(input: { previousTurn?: { runId: string; task: { title: string; description: string | null } } | null; conversationMode?: boolean; agentId: string; + agentKeyId?: string; workspace: { id: string; cwd: string; @@ -207,6 +208,7 @@ export function buildNativeExecutionInput(input: { runId: input.runId, issueId: input.issue.id, agentId: input.agentId, + ...(input.agentKeyId ? { agentKeyId: input.agentKeyId } : {}), executionWorkspaceId: input.workspace.id, }, task: { diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts index f1ef9b31cf8..cb5d0ef8bb1 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -6271,6 +6271,23 @@ describe("native warm session supervision", () => { return { base, next, run }; } + it("replaces an older warm process before the first turn with cryptographic identity", async () => { + const { base } = fixture("identity-key-upgrade"); + const next = { ...base, binding: { ...base.binding, runId: "identity-key-next" } }; + const close = vi.fn(async () => undefined); + state.execute.mockReset().mockImplementationOnce(async options => { + options.onSession?.({ close }); return result; + }).mockImplementationOnce(async options => { + expect(close).toHaveBeenCalledWith({ reason: "warm native session configuration changed" }); + expect(options.existingSession).toBeUndefined(); + return result; + }); + await executePaperclipNativeSession({ db: leaseDb(base), execution: base, runnerInstanceId: "runner" }); + await executePaperclipNativeSession({ db: leaseDb(next), execution: next, runnerInstanceId: "runner", + runnerEnvironment: { PAPERCLIP_AGENT_KEY_ID: "sha256:identity-key" } }); + expect(state.execute).toHaveBeenCalledTimes(2); + }); + it("awaits prior idle ownership retirement before launching the accepted-plan session", async () => { const { base, next, run } = fixture("identity-handoff"); let finishClose!: () => void; diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 981169652ec..6a2bfd0a708 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -3,6 +3,7 @@ import { readLocalAiCredentialFile } from "../local-ai-credential-file.js"; import { prepareGrokRunnerCredentials } from "./grok-runner-credentials.js"; import { copyBackGrokAuth } from "@paperclipai/adapter-grok-local/server"; +import { createAgentIdentityRedactor } from "../agent-identity-redaction.js"; import { isSupportedRemoteCodexVersion, parseCodexCliVersion, @@ -5256,6 +5257,7 @@ function nativeSessionConfigDigest( .update( JSON.stringify({ companyId: execution.binding.companyId, + ...(execution.binding.agentKeyId ? { agentKeyId: execution.binding.agentKeyId } : {}), normalizedSessionId: nativeSessionKey(execution), executionLocation, provider: execution.provider, @@ -5308,6 +5310,7 @@ function nativeHarnessEnvironmentFingerprint( .update( canonicalJson({ companyId: execution.binding.companyId, + ...(execution.binding.agentKeyId ? { agentKeyId: execution.binding.agentKeyId } : {}), agentId: execution.binding.agentId, issueId: execution.binding.issueId, normalizedSessionId: nativeSessionKey(execution), @@ -7294,6 +7297,14 @@ export async function executePaperclipNativeSession(input: { }, ) => Promise; }): Promise { + const agentKeyId = input.runnerEnvironment?.PAPERCLIP_AGENT_KEY_ID; + if (agentKeyId) { + // Recovered pre-upgrade inputs also need the current public identity marker + // before deciding whether an already-running provider can be reused. + input = { ...input, execution: { ...input.execution, + binding: { ...input.execution.binding, agentKeyId }, + } }; + } const runId = input.execution.binding.runId; if (nativeSessionStartups.has(runId)) { throw new Error("native_session_supervisor_busy"); @@ -7853,6 +7864,7 @@ async function executePaperclipNativeSessionWithinScope( controlPlaneSourceInstanceId: controlPlaneInstanceId, }, { + privateKeyPem: input.runnerEnvironment?.PAPERCLIP_AGENT_PRIVATE_KEY, onCommittedEvent: async (event) => { await toolTrace.observe(event); if (event.eventType === "item.completed" && @@ -8412,6 +8424,7 @@ async function executePaperclipNativeSessionWithinScope( requireSessionCloseBeforeReturn: runnerdBackend !== null || input.instructionWorkingCopy !== undefined, onSessionClosed: input.instructionWorkingCopy?.collectStopped, onCheckpoint: async (snapshot) => { + snapshot = createAgentIdentityRedactor(input.runnerEnvironment?.PAPERCLIP_AGENT_PRIVATE_KEY).redact(snapshot); if (warmSessionId !== null && warmConfigDigest !== null) { await persistWarmNativeCheckpoint( input.execution, diff --git a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts index e203a551754..892e1f363b1 100644 --- a/server/src/services/native-runtime/paperclip-control-plane-port.test.ts +++ b/server/src/services/native-runtime/paperclip-control-plane-port.test.ts @@ -1,6 +1,6 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { asc, eq, sql } from "drizzle-orm"; -import { randomUUID } from "node:crypto"; +import { generateKeyPairSync, randomUUID } from "node:crypto"; import { renderPaperclipWakePrompt } from "@paperclipai/adapter-utils/server-utils"; import { readCompletedAssistantMessageCandidate, resolveHeartbeatRunResponse, selectHeartbeatRunFinalAgentMessage } from "../heartbeat-run-summary.js"; import { @@ -299,6 +299,47 @@ describe("PaperclipControlPlanePort conformance", () => { } }); + it("redacts identity private material before persisting native events and checkpoints", async () => { + const identity = { ...CONTROL_PLANE_CONFORMANCE_OPEN.identity, runId: randomUUID(), sessionId: randomUUID() }; + const runnerId = randomUUID(); + const privateKeyPem = generateKeyPairSync("ed25519").privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const keyBody = privateKeyPem.split("\n")[1]; + await db.insert(heartbeatRuns).values({ + id: identity.runId, companyId: identity.companyId, agentId: identity.agentId, + status: "running", runtimeMode: "native", nativeIssueId: identity.issueId, + nativeSessionId: identity.sessionId, runnerInstanceId: runnerId, + completionContractId: contractId, completionContractSha256: contractSha, + contextSnapshot: { issueId: identity.issueId }, + }); + const observed: PrpEvent[] = []; + const port = new PaperclipControlPlanePort(db, { + ...identity, completionContractId: contractId, completionContractSha256: contractSha, + sourceInstanceId: runnerId, controlPlaneSourceInstanceId: `identity-${identity.runId}`, + }, { privateKeyPem, onCommittedEvent: async event => { observed.push(event); } }); + await port.openRun({ identity, backendKind: "mock", sourceInstanceId: runnerId }); + await port.appendEvent({ + schema: "paperclip.prp.event.v1", sourceEventId: `${runnerId}:1`, sourceSeq: 1, + sourceInstanceId: runnerId, sourceKind: "runner", runId: identity.runId, + normalizedSessionId: identity.sessionId, eventType: "tool.execution.started", schemaVersion: 1, + priority: 0, emittedAt: new Date().toISOString(), payload: { + schema: "paperclip.tool.execution.v1", executionId: "identity-output", transport: "builtin", + operation: "execute", name: "diagnostic", target: null, namespace: null, readOnly: true, + status: "running", durationMs: null, exitCode: null, progress: null, + output: privateKeyPem, outputBytes: Buffer.byteLength(privateKeyPem), outputTruncated: false, + outputDigest: `sha256:${"a".repeat(64)}`, + }, + }); + await port.checkpointSession({ backendKind: "mock", sessionId: identity.sessionId, identity, + semanticResult: { ...CONTROL_PLANE_CONFORMANCE_RESULT, summary: privateKeyPem }, + }); + const persistedEvents = await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, identity.runId)); + const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, identity.runId)); + for (const value of [persistedEvents, observed, run.runnerProfileJson]) { + expect(JSON.stringify(value)).not.toContain(keyBody); + expect(JSON.stringify(value)).toContain("***REDACTED***"); + } + }); + it("runs the unchanged package conformance suite against Paperclip persistence", async () => { const identity = CONTROL_PLANE_CONFORMANCE_OPEN.identity; const committedEventIds: string[] = []; diff --git a/server/src/services/native-runtime/paperclip-control-plane-port.ts b/server/src/services/native-runtime/paperclip-control-plane-port.ts index 328d6098e88..0afdea0261d 100644 --- a/server/src/services/native-runtime/paperclip-control-plane-port.ts +++ b/server/src/services/native-runtime/paperclip-control-plane-port.ts @@ -1,3 +1,4 @@ +import { createAgentIdentityRedactor } from "../agent-identity-redaction.js"; import { and, asc, eq, gt, or } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { @@ -71,6 +72,7 @@ function assertTerminal(value: unknown): asserts value is PrpTerminalState { /** Production implementation of the runner package's deliberately narrow persistence port. */ export class PaperclipControlPlanePort implements ControlPlanePort { readonly #db: Db; + readonly #identityRedactor: ReturnType; readonly #binding: PaperclipControlPlaneBinding; #sessionId: string | null = null; readonly #onCommittedEvent?: (event: PrpEvent) => Promise; @@ -80,11 +82,14 @@ export class PaperclipControlPlanePort implements ControlPlanePort { db: Db, binding: PaperclipControlPlaneBinding, options: { + /** Runtime-only secret; never part of the persisted binding. */ + privateKeyPem?: string; onCommittedEvent?: (event: PrpEvent) => Promise; onDuplicateEvent?: (event: PrpEvent) => Promise; } = {}, ) { this.#db = db; + this.#identityRedactor = createAgentIdentityRedactor(options.privateKeyPem); this.#binding = structuredClone(binding); this.#onCommittedEvent = options.onCommittedEvent; this.#onDuplicateEvent = options.onDuplicateEvent; @@ -151,6 +156,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { } async checkpointSession(snapshot: PersistedNativeSession): Promise { + snapshot = this.#identityRedactor.redact(snapshot); const identity = snapshot.identity; if ( identity.companyId !== this.#binding.companyId @@ -183,7 +189,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { if (!isPrpEvent(value)) throw new Error("native_legacy_event_not_supported"); const validated = validatePrpEvent(value); if (!validated.ok) throw new Error(`native_event_schema_invalid:${validated.issues[0]?.message ?? "unknown"}`); - const event = validated.event; + const event = this.#identityRedactor.redact(validated.event); if (event.runId !== this.#binding.runId || (this.#sessionId && event.normalizedSessionId !== this.#sessionId)) { throw new Error("native_event_binding_mismatch"); } @@ -264,6 +270,7 @@ export class PaperclipControlPlanePort implements ControlPlanePort { } async completeRun(value: NativeRunResult | CompleteControlPlaneRunInput): Promise { + value = this.#identityRedactor.redact(value); if (!isCompleteInput(value)) throw new Error("native_structured_result_required"); // Capture compatibility diagnostics before canonical validation removes // legacy/non-actionable attention payloads. They are operator evidence, diff --git a/ui/src/api/agents.ts b/ui/src/api/agents.ts index 7bd9cce9855..f3b6fef3e57 100644 --- a/ui/src/api/agents.ts +++ b/ui/src/api/agents.ts @@ -1,3 +1,4 @@ +import type { AgentPublicIdentity } from "@paperclipai/shared"; import type { Agent, ConnectionIntentInteraction, @@ -109,6 +110,8 @@ function agentPath(id: string, companyId?: string, suffix = "") { } export const agentsApi = { + getIdentity: (id: string, companyId?: string) => + api.get(agentPath(id, companyId, "/identity")), adoptAiConnection: (agentId: string, interactionId: string, connectionId: string, companyId: string) => api.post(withCompanyScope(`/agents/${agentId}/connection-intents/${interactionId}/adopt`, companyId), { connectionId }), diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 6a961016db2..8e0c8ff2479 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -217,6 +217,7 @@ export const queryKeys = { ["team-catalog", "installed", companyId] as const, }, agents: { + identity: (id: string) => ["agents", "identity", id] as const, list: (companyId: string) => ["agents", companyId] as const, detail: (id: string) => ["agents", "detail", id] as const, runtimeState: (id: string) => ["agents", "runtime-state", id] as const, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 84a616a77be..308116e7b34 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -1728,7 +1728,10 @@ export function AgentOverview({ ?? asNonEmptyString(agent.runtimeConfig?.model) ?? "Adapter default"; const lastRun = runs[0] ?? null; - + const identity = useQuery({ + queryKey: [...queryKeys.agents.identity(agent.id), lastRun?.id, lastRun?.status], + queryFn: () => agentsApi.getIdentity(agent.id, agent.companyId), + }); return (
@@ -1750,6 +1753,20 @@ export function AgentOverview({ ) : Board} {directReportCount} + + {identity.isPending ? Loading… + : identity.isError ? Could not load public key + : identity.data ? ( + + {identity.data.keyId.slice(0, 19)}… + + ) : ( +
+
Not created yet
+
Created automatically on the next managed run
+
+ )} +