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..f8dabb3c54a --- /dev/null +++ b/doc/AGENT-IDENTITY.md @@ -0,0 +1,100 @@ +# 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. +Native output deltas also buffer potential key fragments before persistence. +Raw provider trace contents are suppressed for identity-enabled runs; trace +routing, timing, and interpretation metadata remain available. + +Managed remote providers, including Cursor Cloud, receive the same private key +as managed local processes. Selecting a runtime therefore trusts its host with +the agent's persistent identity: the agent and its host can retain the key and +sign outside Paperclip. Encryption at rest protects database storage, not an +executing runtime. Move an agent only between runtime hosts trusted with that +identity. + +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. + +Native failure records and reports redact the assigned key before truncating diagnostics. Streaming output buffers settle at item or turn completion: short structural prefixes (such as a trailing dash) are preserved, longer interrupted key fragments become redaction markers, and pending buffers are cleared. Terminal events carry these settled `outputTails`; transcript projection displays them as deltas without changing the source event receipt. + +Codex shell delivery preserves its default `KEY`/`SECRET`/`TOKEN` name exclusions for other configured credentials. The exceptions are the three identity variables and `PAPERCLIP_API_KEY`: the latter is the short-lived, scoped run/bridge credential required by the Paperclip agent skill’s Bash/curl API calls. Disabling Codex’s automatic exclusions is paired with this explicit filtered allowlist; it does not admit arbitrary host environment variables. Provider authentication secrets can still reach the provider process without being newly exposed to shell commands by this feature. diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 6bb4307f58a..c05b9d6d3aa 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -570,3 +570,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.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 4296a110e79..faa2b6e7a0d 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -172,6 +172,7 @@ async function runExecutor( runtime?: Record; executionTransport?: Record; authToken?: string; + agentIdentity?: AdapterExecutionContext["agentIdentity"]; executionTarget?: Record; runtimeMcp?: AdapterRuntimeMcpAccess; prepareRemoteManagedHome?: AcpxEngineExecutorOptions["prepareRemoteManagedHome"]; @@ -210,6 +211,7 @@ async function runExecutor( context: options.context ?? {}, executionTransport: options.executionTransport, authToken: options.authToken, + agentIdentity: options.agentIdentity, executionTarget: options.executionTarget, runtimeMcp: options.runtimeMcp, startupTraceContext: options.startupTraceContext, @@ -548,6 +550,20 @@ describe("shared ACPX engine runtime behavior", () => { ); }); + it("keeps identity and scoped API access without exposing configured service tokens to Codex shells", async () => { + const { meta } = await runExecutor({ agent: "codex", env: { MY_SERVICE_TOKEN: "assigned-tool-token" } }, { + authToken: "assigned-run-token", + agentIdentity: { keyId: "sha256:test", publicKeyPem: "public", privateKeyPem: "private" }, + }); + const config = JSON.parse(String((meta[0]?.env as Record).CODEX_CONFIG)); + expect(config.shell_environment_policy.include_only).toEqual(expect.arrayContaining([ + "PAPERCLIP_API_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", + ])); + expect(config.shell_environment_policy.include_only).not.toContain("MY_SERVICE_TOKEN"); + expect(JSON.stringify(config)).not.toContain("assigned-run-token"); + expect(JSON.stringify(config)).not.toContain("assigned-tool-token"); + }); + it("forwards arbitrary Codex model IDs verbatim without picker-dependent session config", async () => { const arbitraryModel = "gpt-999-test-does-not-exist"; const { configOptions, meta } = await runExecutor({ diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 28e5b620259..725d733ab6c 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,13 @@ 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", + // Preserve Codex's default secret-name exclusions. The short-lived + // Paperclip API token is required by the agent skill's Bash/curl calls. + ...Object.keys(env).filter(key => key === "PAPERCLIP_API_KEY" || !/key|secret|token/i.test(key)), + ])] } : {}), requestedModel, requestedThinkingEffort, fastMode, @@ -2203,6 +2216,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 afterEach(async () => { vi.clearAllMocks(); + vi.unstubAllEnvs(); if (savedCodexHomeEnv === undefined) { delete process.env.CODEX_HOME; } else { @@ -117,7 +118,7 @@ describe("codex execute — outbound auth copy-back restore contribution", () => ); } - async function runTeardown(input: { sandboxAuth: string; hostAuth: string; onProviderStopped?: () => Promise }) { + async function runTeardown(input: { sandboxAuth: string; hostAuth: string; onProviderStopped?: () => Promise; withIdentity?: boolean }) { const rootDir = await mkdtemp( path.join(os.tmpdir(), "paperclip-codex-copyback-e2e-"), ); @@ -136,8 +137,14 @@ describe("codex execute — outbound auth copy-back restore contribution", () => process.env.CODEX_HOME = sharedHostHome; sandboxAuthFixture.bytes = Buffer.from(input.sandboxAuth, "utf8"); + const commandArgs: string[] = []; const executionResult = await execute({ runId: "run-copyback-e2e", + ...(input.withIdentity ? { + authToken: "assigned-run-token", + agentIdentity: { keyId: "sha256:test", publicKeyPem: "public", privateKeyPem: "private" }, + } : {}), + onMeta: async meta => { commandArgs.push(...(meta.commandArgs ?? [])); }, onProviderStopped: input.onProviderStopped, agent: { id: "agent-1", @@ -152,7 +159,7 @@ describe("codex execute — outbound auth copy-back restore contribution", () => engine: "cli", // External CODEX_HOME (outside the managed company tree) so no managed // seeding rewrites auth.json before teardown; equals the shared host home. - env: { CODEX_HOME: sharedHostHome }, + env: { CODEX_HOME: sharedHostHome, ...(input.withIdentity ? { MY_SERVICE_TOKEN: "assigned-tool-token" } : {}) }, }, context: { paperclipWorkspace: { @@ -176,12 +183,29 @@ describe("codex execute — outbound auth copy-back restore contribution", () => }); return { + commandArgs, finalHostAuth: await readFile(hostAuthPath, "utf8"), finalHostMode: (await lstat(hostAuthPath)).mode & 0o777, executionResult, }; } + it("keeps identity and scoped API access without exposing configured service tokens to CLI shells", async () => { + vi.stubEnv("DATABASE_URL", "postgres://operator:host-password@host/private"); + vi.stubEnv("HOST_DATABASE_PASSWORD", "host-password"); + const { commandArgs } = await runTeardown({ sandboxAuth: "{}", hostAuth: "{}", withIdentity: true }); + const policy = commandArgs.find(arg => arg.startsWith("shell_environment_policy.include_only=")); + const keys = JSON.parse(policy!.slice(policy!.indexOf("=") + 1)); + expect(keys).toEqual(expect.arrayContaining([ + "PAPERCLIP_API_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", + ])); + expect(keys).not.toContain("MY_SERVICE_TOKEN"); + expect(keys).not.toContain("DATABASE_URL"); + expect(keys).not.toContain("HOST_DATABASE_PASSWORD"); + expect(commandArgs.join(" ")).not.toContain("assigned-run-token"); + expect(commandArgs.join(" ")).not.toContain("assigned-tool-token"); + }); + it("collects stopped-provider instruction edits before a throwing remote restore", async () => { const order: string[] = []; prepareAdapterExecutionTargetRuntime.mockImplementationOnce(async () => ({ diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index fe2fa38b588..78e6594c519 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -754,7 +754,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise ({ name: server.name, endpointPath: server.url, @@ -1228,6 +1228,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise key === "PAPERCLIP_API_KEY" || !/key|secret|token/i.test(key)), + ...identityNames, + ])]; + 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.test.ts b/packages/adapters/cursor-cloud/src/server/execute.test.ts index b57f8207814..156ed5d9607 100644 --- a/packages/adapters/cursor-cloud/src/server/execute.test.ts +++ b/packages/adapters/cursor-cloud/src/server/execute.test.ts @@ -1,3 +1,4 @@ +import { generateKeyPairSync, sign, verify } from "node:crypto"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { AdapterExecutionContext } from "@paperclipai/adapter-utils"; import { createPromptContextFixture } from "@paperclipai/adapter-utils/test-fixtures/prompt-context"; @@ -266,6 +267,39 @@ describe("cursor_cloud execute", () => { ); }); + it("delivers the assigned identity to the managed cloud environment without exposing it in prompts or metadata", async () => { + const keys = generateKeyPairSync("ed25519"); + const identity = { + keyId: "sha256:assigned-identity", + publicKeyPem: keys.publicKey.export({ type: "spki", format: "pem" }).toString(), + privateKeyPem: keys.privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + }; + const sdkAgent = createMockSdkAgent(); + createMock.mockResolvedValue(sdkAgent); + const ctx = createContext({ agentIdentity: identity }); + ctx.config.env = { + CURSOR_API_KEY: "cursor-secret", + PAPERCLIP_AGENT_KEY_ID: "configured-override", + PAPERCLIP_AGENT_PRIVATE_KEY: "configured-override", + paperclip_agent_private_key: "configured-override", + }; + + await execute(ctx); + + const env = createMock.mock.calls[0]?.[0]?.cloud?.envVars; + expect(env).toMatchObject({ + PAPERCLIP_AGENT_KEY_ID: identity.keyId, + PAPERCLIP_AGENT_PUBLIC_KEY: identity.publicKeyPem, + PAPERCLIP_AGENT_PRIVATE_KEY: identity.privateKeyPem, + }); + expect(env).not.toHaveProperty("paperclip_agent_private_key"); + const challenge = Buffer.from("managed cloud identity"); + expect(verify(null, challenge, identity.publicKeyPem, + sign(null, challenge, env.PAPERCLIP_AGENT_PRIVATE_KEY))).toBe(true); + const privateBody = identity.privateKeyPem.split("\n")[1]; + expect(JSON.stringify([ctx.meta, ctx.logs, sdkAgent.send.mock.calls])).not.toContain(privateBody); + }); + it("omits empty environment values while preserving nonempty values exactly", async () => { createMock.mockResolvedValue(createMockSdkAgent()); const ctx = createContext(); diff --git a/packages/adapters/cursor-cloud/src/server/execute.ts b/packages/adapters/cursor-cloud/src/server/execute.ts index 48e62b10eea..3a14ba1a20b 100644 --- a/packages/adapters/cursor-cloud/src/server/execute.ts +++ b/packages/adapters/cursor-cloud/src/server/execute.ts @@ -17,6 +17,7 @@ import { asString, buildPaperclipEnv, buildRuntimeToolsEnv, + isForbiddenConfigEnvKey, joinPromptSections, parseObject, readPaperclipIssueWorkModeFromContext, @@ -59,6 +60,7 @@ function asStringEnvMap(value: unknown): Record { const parsed = parseObject(value); const env: Record = {}; for (const [key, entry] of Object.entries(parsed)) { + if (isForbiddenConfigEnvKey(key)) continue; if (typeof entry === "string") { env[key] = entry; } else if (typeof entry === "object" && entry !== null && !Array.isArray(entry)) { @@ -108,7 +110,9 @@ function buildWakeEnv(ctx: AdapterExecutionContext, configEnv: Record = { ...configEnv, - ...buildPaperclipEnv(agent), + // The selected managed cloud host is trusted with this persistent identity, + // just like a managed local host (see doc/AGENT-IDENTITY.md). + ...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/0308_whole_steel_serpent.sql b/packages/db/src/migrations/0308_whole_steel_serpent.sql new file mode 100644 index 00000000000..81c5ef748ae --- /dev/null +++ b/packages/db/src/migrations/0308_whole_steel_serpent.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/0303_snapshot.json b/packages/db/src/migrations/meta/0308_snapshot.json similarity index 99% rename from packages/db/src/migrations/meta/0303_snapshot.json rename to packages/db/src/migrations/meta/0308_snapshot.json index b3a8140db55..e294651897e 100644 --- a/packages/db/src/migrations/meta/0303_snapshot.json +++ b/packages/db/src/migrations/meta/0308_snapshot.json @@ -1,6 +1,6 @@ { - "id": "300dc8d4-29f3-4eb5-8498-3ecc69e4ea0a", - "prevId": "befbd58f-acb8-4fd4-bb64-4c1c0cfd0f3e", + "id": "00d98f2c-c054-4ca3-91eb-08211bdf418d", + "prevId": "ff1eeb0a-fea3-4cd1-b45c-ce712c4667ff", "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": "", @@ -2894,7 +2971,7 @@ "checkConstraints": { "ai_connection_defaults_provider_check": { "name": "ai_connection_defaults_provider_check", - "value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai')" + "value": "\"ai_connection_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')" }, "ai_connection_defaults_method_check": { "name": "ai_connection_defaults_method_check", @@ -3367,7 +3444,7 @@ "checkConstraints": { "ai_provider_defaults_provider_check": { "name": "ai_provider_defaults_provider_check", - "value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai')" + "value": "\"ai_provider_defaults\".\"provider\" in ('anthropic','openai','openrouter','xai','google')" } }, "isRLSEnabled": false @@ -39410,7 +39487,7 @@ }, "resource_lifecycle_events_action_check": { "name": "resource_lifecycle_events_action_check", - "value": "\"resource_lifecycle_events\".\"action\" = 'create' OR (\"resource_lifecycle_events\".\"resource_type\" = 'project' AND \"resource_lifecycle_events\".\"action\" = 'update') OR (\"resource_lifecycle_events\".\"resource_type\" = 'agent' AND \"resource_lifecycle_events\".\"action\" IN ('pause', 'resume', 'terminate'))" + "value": "\"resource_lifecycle_events\".\"action\" = 'create' OR (\"resource_lifecycle_events\".\"resource_type\" = 'project' AND \"resource_lifecycle_events\".\"action\" IN ('update', 'archive')) OR (\"resource_lifecycle_events\".\"resource_type\" = 'agent' AND \"resource_lifecycle_events\".\"action\" IN ('pause', 'resume', 'terminate'))" } }, "isRLSEnabled": false @@ -51701,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", @@ -51717,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": "", @@ -51842,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": "", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index ef06669ccd9..5daf7757de5 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2143,6 +2143,13 @@ "when": 1791311441769, "tag": "0307_cool_naoko", "breakpoints": true + }, + { + "idx": 308, + "version": "7", + "when": 1791312251106, + "tag": "0308_whole_steel_serpent", + "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 61d66307dcf..e672dd33f97 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 @@ -118,6 +118,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 e5a93bf12bb..29494a4e21a 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 @@ -34,6 +34,9 @@ const WARM_ATTACHMENT_DRAIN_DEADLINE: Duration = Duration::from_millis(100); const OPENCODE_PROVIDER_ENVIRONMENT_KEYS: &[&str] = &[ "PAPERCLIP_AI_PROVIDER_KEY", "PAPERCLIP_AI_PROVIDER_URL", + "PAPERCLIP_AGENT_KEY_ID", + "PAPERCLIP_AGENT_PUBLIC_KEY", + "PAPERCLIP_AGENT_PRIVATE_KEY", "OPENROUTER_API_KEY", "PAPERCLIP_NATIVE_MCP_NAME", "PAPERCLIP_NATIVE_MCP_URL", @@ -244,6 +247,15 @@ impl ProviderTraceSink { } fn frame(&mut self, direction: &str, raw: &[u8]) -> Option { + // Raw protocol frames can contain arbitrary private-key fragments. + // Preserve trace metadata without retaining raw content for identity runs. + let redacted = + if std::env::var("PAPERCLIP_AGENT_PRIVATE_KEY").is_ok_and(|key| !key.is_empty()) { + "[REDACTED: agent identity runtime]".to_owned() + } else { + String::from_utf8_lossy(raw).into_owned() + }; + let raw = redacted.as_bytes(); if self.captured_bytes.saturating_add(raw.len()) > self.max_bytes { self.truncated = true; return None; @@ -795,6 +807,9 @@ const GITHUB_CREDENTIAL_ENVIRONMENT_KEYS: &[&str] = &[ const CODEX_PROVIDER_ENVIRONMENT_KEYS: &[&str] = &[ "PAPERCLIP_AI_PROVIDER_KEY", + "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/state.rs b/packages/paperclip-runner/runner/crates/runner-core/src/durable/state.rs index 894a00176c3..92f35e6db21 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 @@ -312,6 +312,8 @@ pub enum CommandDisposition { #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct DurableState { + #[serde(skip)] + identity_output: IdentityOutputBuffer, pub schema: String, pub runner_instance_id: String, pub environment_lease_id: String, @@ -352,6 +354,7 @@ pub struct DurableState { impl DurableState { pub(crate) fn new(config: &DurableRunnerConfig) -> Self { Self { + identity_output: IdentityOutputBuffer::default(), schema: STATE_SCHEMA.to_owned(), runner_instance_id: config.runner_instance_id.clone(), environment_lease_id: config.environment_lease_id.clone(), @@ -527,6 +530,37 @@ impl DurableState { } validate_semantic_tool_input_digest(event_type.as_str(), &payload)?; + // Clone until enqueue succeeds: rejected writes must not consume output. + // Pending secret fragments are memory-only and never enter durable state. + let mut identity_output = self.identity_output.clone(); + let payload = if event_type == "item.delta" { + identity_output.redact( + &format!("{}:{}", self.turn_id, self.item_id), + &payload, + &self.item_id, + ) + } else if matches!( + event_type.as_str(), + "item.completed" | "item.failed" | "item.cancelled" + ) { + identity_output.settle( + &format!("{}:{}", self.turn_id, self.item_id), + &payload, + false, + ) + } else if matches!( + event_type.as_str(), + "turn.completed" + | "turn.failed" + | "turn.cancelled" + | "turn.interrupted" + | "session.closed" + ) { + identity_output.settle(&format!("{}:", self.turn_id), &payload, true) + } else { + payload + }; + let sanitized_payload = preserve_bounded_display_content( event_type.as_str(), &payload, @@ -634,6 +668,7 @@ impl DurableState { ); } self.peak_outbox_bytes = self.peak_outbox_bytes.max(projected); + self.identity_output = identity_output; Ok(source_seq) } @@ -1626,6 +1661,199 @@ fn durable_semantics_changed_by_sanitization(original: &Value, sanitized: &Value } } +#[derive(Clone, Default, PartialEq)] +struct IdentityOutputBuffer { + pending: BTreeMap, + streams: BTreeMap, +} + +#[derive(Clone, PartialEq)] +struct IdentityOutputStream { + scope: String, + item_id: Option, + payload: Value, + paths: Vec, +} + +impl std::fmt::Debug for IdentityOutputBuffer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("IdentityOutputBuffer { [REDACTED] }") + } +} + +impl IdentityOutputBuffer { + fn redact(&mut self, stream: &str, payload: &Value, item_id: &str) -> Value { + let Ok(key) = std::env::var("PAPERCLIP_AGENT_PRIVATE_KEY") else { + return payload.clone(); + }; + if key.is_empty() { + return payload.clone(); + } + let result = self.redact_with_key(stream, payload, &key); + if let Some(descriptor) = self.streams.get_mut(&Self::stream_key(stream, payload)) { + descriptor.item_id = Some(json!(item_id)); + } + result + } + + fn stream_key(stream: &str, payload: &Value) -> String { + json!([ + stream, + payload.get("itemId"), + payload.get("providerItemId"), + payload.get("channel"), + payload.get("stream") + ]) + .to_string() + } + + fn redact_with_key(&mut self, stream: &str, payload: &Value, key: &str) -> Value { + let scope = stream.to_owned(); + let stream = Self::stream_key(stream, payload); + let mut values = vec![key.to_owned(), key.trim().to_owned()]; + values.extend( + key.lines() + .filter(|line| !line.is_empty() && !line.starts_with("-----")) + .map(str::to_owned), + ); + values.sort_by_key(|value| std::cmp::Reverse(value.len())); + let result = self.visit(&stream, "", payload, &values); + let prefix = format!("{stream}."); + let paths: Vec = self + .pending + .keys() + .filter(|path| path.starts_with(&prefix)) + .cloned() + .collect(); + if paths.is_empty() { + self.streams.remove(&stream); + } else { + let metadata = ["itemId", "providerItemId", "kind", "channel", "stream"] + .iter() + .filter_map(|key| { + payload + .get(*key) + .filter(|value| value.is_string()) + .map(|value| ((*key).to_owned(), sanitize_value(value))) + }) + .collect(); + self.streams.insert( + stream, + IdentityOutputStream { + scope, + item_id: None, + payload: Value::Object(metadata), + paths, + }, + ); + } + result + } + + fn settle(&mut self, scope: &str, payload: &Value, whole_turn: bool) -> Value { + let streams: Vec = self + .streams + .iter() + .filter(|(_, descriptor)| { + (if whole_turn { + descriptor.scope.starts_with(scope) + } else { + descriptor.scope == scope + }) && (whole_turn + || payload.get("itemId").is_none() + || descriptor.payload.get("itemId").is_none() + || payload.get("itemId") == descriptor.payload.get("itemId")) + }) + .map(|(stream, _)| stream.clone()) + .collect(); + let mut tails = Vec::new(); + for stream in streams { + let descriptor = self.streams.remove(&stream).unwrap(); + let primary = descriptor + .paths + .iter() + .find(|path| **path == format!("{stream}.text")) + .or_else(|| descriptor.paths.first()) + .cloned(); + let mut text = String::new(); + for path in descriptor.paths { + if let Some(held) = self.pending.remove(&path) { + if Some(&path) == primary.as_ref() { + text = if held.len() < 8 { + held + } else { + "[REDACTED]".to_owned() + }; + } + } + } + if !text.is_empty() { + let mut tail_payload = descriptor.payload; + tail_payload["text"] = json!(text); + tails.push(json!({"itemId": descriptor.item_id, "payload": tail_payload})); + } + } + let mut result = payload.clone(); + if !tails.is_empty() { + let mut existing = result + .get("outputTails") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + existing.extend(tails); + result["outputTails"] = json!(existing); + } + result + } + + fn visit(&mut self, path: &str, field: &str, value: &Value, secrets: &[String]) -> Value { + match value { + Value::Object(object) => Value::Object( + object + .iter() + .map(|(key, child)| { + ( + key.clone(), + self.visit(&format!("{path}.{key}"), key, child, secrets), + ) + }) + .collect(), + ), + Value::Array(array) => Value::Array( + array + .iter() + .enumerate() + .map(|(index, child)| { + self.visit(&format!("{path}.{index}"), field, child, secrets) + }) + .collect(), + ), + Value::String(chunk) if matches!(field, "text" | "delta" | "output" | "patch") => { + let mut text = self.pending.remove(path).unwrap_or_default(); + text.push_str(chunk); + for secret in secrets { + text = text.replace(secret, "[REDACTED]"); + } + let mut held = 0; + for secret in secrets { + for size in (held + 1..secret.len().min(text.len() + 1)).rev() { + if secret.is_char_boundary(size) && text.ends_with(&secret[..size]) { + held = size; + break; + } + } + } + if held > 0 { + let tail = text.split_off(text.len() - held); + self.pending.insert(path.to_owned(), tail); + } + Value::String(text) + } + other => other.clone(), + } + } +} + pub(crate) fn sanitize_value(value: &Value) -> Value { match value { Value::Object(object) => Value::Object( @@ -1824,6 +2052,9 @@ fn validate_semantic_tool_input_digest( } pub(crate) fn redact_text(input: &str) -> String { + // Cutting a secret first leaves an unmatchable plaintext fragment. + let redacted = redact_sensitive_text_values(input); + let input = redacted.as_str(); let (bounded, truncated) = if input.len() > 4096 { let boundary = input .char_indices() @@ -1835,14 +2066,37 @@ pub(crate) fn redact_text(input: &str) -> String { } else { (input, false) }; - let mut redacted = redact_sensitive_text_values(bounded); + let mut redacted = bounded.to_owned(); if truncated { redacted.push_str("…[truncated]"); } 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(); @@ -2360,6 +2614,64 @@ fn current_timestamp() -> Result { #[cfg(test)] mod tests { + #[test] + fn identity_delta_redaction_buffers_every_split_and_never_serializes_pending_material() { + use super::*; + let key = "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIExamplePrivateMaterial\n-----END PRIVATE KEY-----\n"; + let body = key.lines().nth(1).unwrap(); + for split in 1..body.len() { + let mut buffer = IdentityOutputBuffer::default(); + let first = buffer.redact_with_key( + "envelope-item", + &json!({"itemId": "item-1", "text": &body[..split], "update": {"delta": &body[..split]}}), + key, + ); + let other = buffer.redact_with_key( + "envelope-item", + &json!({"itemId": "item-2", "text": "ordinary output\n"}), + key, + ); + assert_eq!(other["text"], "ordinary output\n"); + let second = buffer.redact_with_key( + "envelope-item", + &json!({"itemId": "item-1", "text": &body[split..], "update": {"delta": &body[split..]}}), + key, + ); + assert_eq!(first["text"], ""); + assert_eq!(second["text"], "[REDACTED]"); + assert_eq!(second["update"]["delta"], "[REDACTED]"); + } + let mut state = DurableState::new(&config(PathBuf::from("/unused"))); + state + .identity_output + .redact_with_key("item", &json!({"text": &body[..20]}), key); + assert!(!serde_json::to_string(&state).unwrap().contains(&body[..20])); + assert!(!format!("{state:?}").contains(&body[..20])); + } + + #[test] + fn identity_output_settles_item_and_turn_tails_without_retaining_fragments() { + let key = "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEITestSecretMaterialForAnAgentIdentity\n-----END PRIVATE KEY-----\n"; + let mut output = IdentityOutputBuffer::default(); + assert_eq!( + output.redact_with_key( + "turn:item", + &json!({"itemId": "one", "kind": "reasoning", "text": "Thinking-"}), + key + )["text"], + "Thinking" + ); + let settled = output.settle("turn:item", &json!({"itemId": "one"}), false); + assert_eq!(settled["outputTails"][0]["payload"]["text"], "-"); + assert!(output.pending.is_empty()); + assert!(output.streams.is_empty()); + output.redact_with_key("turn:other", &json!({"text": &key[..42]}), key); + let settled = output.settle("turn:", &json!({"status": "completed"}), true); + assert_eq!(settled["outputTails"][0]["payload"]["text"], "[REDACTED]"); + assert!(output.pending.is_empty()); + assert!(output.streams.is_empty()); + } + use std::time::Duration; use super::*; @@ -3954,6 +4266,42 @@ mod tests { ); } + #[test] + fn identity_redaction_precedes_multibyte_truncation() { + let prefix = "MC4CAQAwBQYDK2VwBCIEIA"; + let body = format!("{prefix}{}", "A".repeat(64 - prefix.len())); + let key = format!("-----BEGIN PRIVATE KEY-----\n{body}\n-----END PRIVATE KEY-----\n"); + // Give only this subprocess the key; do not race other tests' environment. + if std::env::var_os("PAPERCLIP_IDENTITY_TRUNCATION_TEST").is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "durable::state::tests::identity_redaction_precedes_multibyte_truncation", + "--nocapture", + ]) + .env("PAPERCLIP_IDENTITY_TRUNCATION_TEST", "1") + .env("PAPERCLIP_AGENT_PRIVATE_KEY", &key) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + for material in [&body, &key] { + let input = format!("{}{material}", "é".repeat(2025)); + let output = redact_text(&input); + assert!(!output.contains(prefix)); + assert!(output.contains("[REDACTED]")); + assert!(output.len() <= 4096); + let persisted = sanitize_value(&json!({"text": input})); + assert!(!persisted.to_string().contains(prefix)); + } + } + #[test] fn diagnostic_redaction_still_bounds_retained_text() { let output = redact_text(&format!("token=secret-value {}", "x".repeat(4_096))); 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/contracts/provider-trace-file-sink.test.ts b/packages/paperclip-runner/src/contracts/provider-trace-file-sink.test.ts index 405a28edd77..fc30e120364 100644 --- a/packages/paperclip-runner/src/contracts/provider-trace-file-sink.test.ts +++ b/packages/paperclip-runner/src/contracts/provider-trace-file-sink.test.ts @@ -14,6 +14,24 @@ afterEach(async () => { }); describe("ProviderTraceFileSink", () => { + it("omits raw identity-run frames even when a key is split into single-byte deltas", async () => { + const root = await mkdtemp(join(tmpdir(), "paperclip-provider-trace-identity-")); + roots.push(root); + const tracePath = join(root, "provider.ndjson"); + const sink = await createProviderTraceFileSink({ path: tracePath, provider: "opencode", channel: "test" }); + const key = "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIExamplePrivateMaterial\n-----END PRIVATE KEY-----\n"; + sink!.addSensitiveValues([key]); + for (const delta of key) sink!.frame({ + direction: "provider_to_client", raw: JSON.stringify({ delta }), transport: "sse", + }); + await sink!.finish(); + const records = (await readFile(tracePath, "utf8")).trim().split("\n").map(line => JSON.parse(line)); + const frames = records.filter(record => record.kind === "frame"); + expect(frames).toHaveLength(key.length); + for (const frame of frames) expect(Buffer.from(frame.rawBase64, "base64").toString()) + .toBe("[REDACTED: agent identity runtime]"); + }); + it("hardens an existing file before it appends provider frames", async () => { const root = await mkdtemp(join(tmpdir(), "paperclip-provider-trace-mode-")); roots.push(root); diff --git a/packages/paperclip-runner/src/contracts/provider-trace-file-sink.ts b/packages/paperclip-runner/src/contracts/provider-trace-file-sink.ts index aff5932900b..666ad36f036 100644 --- a/packages/paperclip-runner/src/contracts/provider-trace-file-sink.ts +++ b/packages/paperclip-runner/src/contracts/provider-trace-file-sink.ts @@ -33,6 +33,7 @@ export class ProviderTraceFileSink { readonly #channel: string; readonly #maxBytes: number; readonly #sensitiveValues = new Set(); + #hasPrivateIdentity = false; readonly #queue: string[] = []; #nextFrameId: number; #nextDebugSequence: number; @@ -62,6 +63,7 @@ export class ProviderTraceFileSink { addSensitiveValues(values: readonly (string | undefined)[]): void { for (const value of values) { + if (value?.includes("-----BEGIN PRIVATE KEY-----")) this.#hasPrivateIdentity = true; if (value) this.#sensitiveValues.add(value); } } @@ -181,6 +183,9 @@ export class ProviderTraceFileSink { } #redact(raw: Buffer): Buffer { + // Raw frames may split a private key at any byte boundary. Keep trace + // routing/timing metadata, but never spool raw content for identity runs. + if (this.#hasPrivateIdentity) return Buffer.from("[REDACTED: agent identity runtime]"); if (this.#sensitiveValues.size === 0) return raw; let value = raw.toString("utf8"); for (const sensitive of [...this.#sensitiveValues].sort( 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 9492b21b60b..64ee0e368ba 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_AI_PROVIDER_KEY: "managed-provider-key", ANTHROPIC_AUTH_TOKEN: "managed-claude-key", @@ -422,6 +425,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_AI_PROVIDER_KEY: "managed-provider-key", ANTHROPIC_AUTH_TOKEN: "managed-claude-key", 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 bbf604d4dce..4c4e20c73f3 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 @@ -3375,6 +3375,9 @@ const runnerExplicitProviderEnvironmentKeys = [ ...CLAUDE_ROUTING_ENV_KEYS, "PAPERCLIP_AI_PROVIDER_KEY", "PAPERCLIP_AI_PROVIDER_URL", + "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 3ce0790542a..2ecbf367474 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.ts @@ -111,6 +111,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, ...(agent === "claude" ? CLAUDE_ROUTING_ENV_KEYS : []), ]); diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts index fb80619a55d..53423467c92 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.test.ts @@ -52,6 +52,26 @@ describe("ACPX runtime sandbox", () => { expect((await stat(requirementsPath)).mode & 0o777).toBe(0o600); }); + it("keeps provider credentials outside identity-enabled Codex shell commands", async () => { + const fixture = await sandboxFixture("codex"); + const sandbox = await prepareAcpxRuntimeSandbox({ + binding: fixture.binding, agent: "codex", environment: { + PATH: process.env.PATH, + OPENAI_API_KEY: "provider-secret", MY_SERVICE_TOKEN: "configured-secret", + PAPERCLIP_API_KEY: "scoped-run-token", + PAPERCLIP_AGENT_KEY_ID: "sha256:test", PAPERCLIP_AGENT_PUBLIC_KEY: "public-identity", + PAPERCLIP_AGENT_PRIVATE_KEY: "private-identity", + }, + }); + const config = await readFile(join(sandbox.agentHomeDirectory, "config.toml"), "utf8"); + const keys = JSON.parse(config.split("\n").find(line => line.startsWith("include_only = "))!.slice("include_only = ".length)); + expect(keys).toEqual(expect.arrayContaining(["PAPERCLIP_API_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY"])); + expect(keys).not.toContain("OPENAI_API_KEY"); + expect(keys).not.toContain("MY_SERVICE_TOKEN"); + expect(sandbox.launchEnvironment.OPENAI_API_KEY).toBe("provider-secret"); + for (const value of ["provider-secret", "configured-secret", "scoped-run-token", "private-identity"]) expect(config).not.toContain(value); + }); + it.each(["claude-sonnet-5", "sonnet", "custom-deployment-id"])( "preserves the requested Claude model %s in its isolated settings", async (model) => { diff --git a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts index 8ecdbc88ede..11df4137364 100644 --- a/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts +++ b/packages/paperclip-runner/src/drivers/acpx/runtime-sandbox.ts @@ -453,6 +453,16 @@ 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", + // Provider/config secrets keep Codex's default shell exclusions; + // only Paperclip's scoped API token is required by Bash/curl skills. + ...Object.keys(input.environment).filter(key => key === "PAPERCLIP_API_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 154b7df2fcc..53ce851d113 100644 --- a/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts +++ b/packages/paperclip-runner/src/drivers/codex/app-server-transport.ts @@ -229,8 +229,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]; @@ -238,6 +240,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 e82b068527a..fa95ea19b4d 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -2130,7 +2130,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("-----"))] : []; const sensitiveValues = [ + ...identityValues, password, authHeader, bridge.secret, @@ -2736,6 +2740,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 b73efc381bc..7c16e9fc024 100644 --- a/packages/paperclip-runner/src/live/runnerd-codex-transport.ts +++ b/packages/paperclip-runner/src/live/runnerd-codex-transport.ts @@ -3213,6 +3213,7 @@ export function resolveRunnerdAcpxPermissionMode( const OPEN_CODE_RUNNER_ENVIRONMENT_KEYS = new Set([ "PAPERCLIP_AI_PROVIDER_KEY", "PAPERCLIP_AI_PROVIDER_URL", + "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", "PATH", "LANG", "LANGUAGE", @@ -3247,6 +3248,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 ee050d555e3..8910f5f93c5 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-default-configure-grants.test.ts b/server/src/__tests__/agent-default-configure-grants.test.ts index e67f68a5453..3094e12f700 100644 --- a/server/src/__tests__/agent-default-configure-grants.test.ts +++ b/server/src/__tests__/agent-default-configure-grants.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { eq } from "drizzle-orm"; -import { agents, companies, companyMemberships, createDb, principalPermissionGrants } from "@paperclipai/db"; +import { activityLog, agents, companies, companyMemberships, createDb, principalPermissionGrants } from "@paperclipai/db"; import { LOW_TRUST_REVIEW_PRESET, type PermissionKey } from "@paperclipai/shared"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { agentService } from "../services/agents.js"; @@ -137,6 +137,7 @@ describeDatabase("new agent configuration defaults", () => { await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId)); await db.delete(companyMemberships).where(eq(companyMemberships.companyId, companyId)); await db.delete(agents).where(eq(agents.companyId, companyId)); + await db.delete(activityLog).where(eq(activityLog.companyId, companyId)); await db.delete(companies).where(eq(companies.id, companyId)); }); @@ -178,6 +179,7 @@ describeDatabase("new agent configuration defaults", () => { await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId)); await db.delete(agents).where(eq(agents.companyId, companyId)); + await db.delete(activityLog).where(eq(activityLog.companyId, companyId)); await db.delete(companies).where(eq(companies.id, companyId)); }); @@ -207,6 +209,7 @@ describeDatabase("new agent configuration defaults", () => { await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId)); await db.delete(agents).where(eq(agents.companyId, companyId)); + await db.delete(activityLog).where(eq(activityLog.companyId, companyId)); await db.delete(companies).where(eq(companies.id, companyId)); }); @@ -235,6 +238,7 @@ describeDatabase("new agent configuration defaults", () => { await db.delete(principalPermissionGrants).where(eq(principalPermissionGrants.companyId, companyId)); await db.delete(companyMemberships).where(eq(companyMemberships.companyId, companyId)); await db.delete(agents).where(eq(agents.companyId, companyId)); + await db.delete(activityLog).where(eq(activityLog.companyId, companyId)); await db.delete(companies).where(eq(companies.id, companyId)); }); }); 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..dcdbf1189b2 --- /dev/null +++ b/server/src/__tests__/agent-identity.test.ts @@ -0,0 +1,256 @@ +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).where(eq(agentIdentityKeys.agentId, agent.id))).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("persists ordinary stdout and stderr endings after settling the identity buffer", async () => { + const agent = await oldAgent(); + await db.update(agents).set({ adapterType: "process", adapterConfig: { + command: process.execPath, cwd: directory, + args: ["-e", "process.stdout.write('safe-'); process.stderr.write('safeM');"], + } }).where(eq(agents.id, agent.id)); + const heartbeat = heartbeatService(db); + try { + const run = await heartbeat.invoke(agent.id, "on_demand", {}, "manual"); + await drainHeartbeatRunsToQuiescence(db, heartbeat); + const finished = await heartbeat.getRun(run!.id); + expect(finished?.status).toBe("succeeded"); + expect(finished?.stdoutExcerpt).toMatch(/safe-$/); + expect(finished?.stderrExcerpt).toMatch(/safeM$/); + const lines = (await heartbeat.readLog(run!.id)).content.trim().split("\n").map(line => JSON.parse(line)); + expect(lines.filter(line => line.stream === "stdout").map(line => line.chunk).join("")).toContain("safe-"); + expect(lines.filter(line => line.stream === "stderr").map(line => line.chunk).join("")).toContain("safeM"); + } 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("settles ordinary endings and interrupted keys on item and turn completion", () => { + const redactor = createAgentIdentityRedactor(pem); + expect(redactor.delta("run:turn:item", { kind: "reasoning", text: "Thinking-" }, "item").text).toBe("Thinking"); + const completed = redactor.settleDeltas("run:turn:item", {}, false); + expect(completed).toMatchObject({ outputTails: [{ itemId: "item", payload: { kind: "reasoning", text: "-" } }] }); + expect(redactor.settleDeltas("run:turn:", {}, true)).toEqual({}); + redactor.delta("run:turn:other", { text: pem.slice(0, 42) }, "other"); + expect(redactor.settleDeltas("run:turn:", {}, true)).toMatchObject({ outputTails: [{ payload: { text: "***REDACTED***" } }] }); + expect(redactor.delta("run:turn:other", { text: "hello" }).text).toBe("hello"); + }); + + 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 418bf322378..5373891b489 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -1,4 +1,5 @@ import { aiRoutingHarness } from "@paperclipai/shared"; +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"; @@ -4386,6 +4387,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/routes/openapi.ts b/server/src/routes/openapi.ts index 88e59062bd6..754c13e5b33 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -3456,6 +3456,25 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound }, }); +registry.registerPath({ + method: "get", + path: "/api/agents/{id}/identity", + tags: ["agents"], + summary: "Get an agent's public cryptographic identity, or null before provisioning", + request: { params: z.object({ id: z.string() }) }, + responses: { + 200: r.ok(z.object({ + algorithm: z.literal("Ed25519"), + keyId: z.string(), + publicKeyPem: z.string(), + createdAt: z.string().datetime(), + }).nullable()), + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + }, +}); + registry.registerPath({ method: "patch", path: "/api/agents/{id}", 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..6bcd1e7d32a --- /dev/null +++ b/server/src/services/agent-identity-redaction.ts @@ -0,0 +1,95 @@ +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(); + const deltaStreams = new Map; paths: string[]; + }>(); + const redactor = { + 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; } + } + } + if (held) pending.set(stream, text.slice(-held)); + else pending.delete(stream); + 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); + // Ed25519 PEM/PKCS#8 values begin with fixed structural bytes. Preserve + // ordinary endings such as a dash; longer interrupted fragments stay hidden. + return held ? (held.length < 8 ? held : REDACTED_EVENT_VALUE) : ""; + }, + /** Delta payloads can repeat output under text and provider-specific fields. */ + delta(stream: string, value: T, itemId?: string): T { + const scope = stream; + const paths: string[] = []; + const payload = value as Record; + // Durable transports can share one envelope item ID across provider items. + stream = JSON.stringify([stream, payload.itemId, payload.providerItemId, payload.channel, payload.stream]); + const visit = (entry: unknown, path: string, field: string): unknown => { + if (typeof entry === "string") { + if (!/^(text|delta|output|patch)$/.test(field)) return redactor.redact(entry); + const key = `${stream}:${path}`; + const output = redactor.chunk(key, entry); + if (pending.has(key)) paths.push(key); + return output; + } + if (Array.isArray(entry)) return entry.map((child, index) => visit(child, `${path}.${index}`, field)); + if (entry && typeof entry === "object") return Object.fromEntries( + Object.entries(entry).map(([key, child]) => [key, visit(child, `${path}.${key}`, key)]), + ); + return entry; + }; + const result = visit(value, "", "") as T; + for (const previous of deltaStreams.get(stream)?.paths ?? []) { + if (pending.has(previous) && !paths.includes(previous)) paths.push(previous); + } + if (paths.length) deltaStreams.set(stream, { scope, itemId, paths, + payload: Object.fromEntries(["itemId", "providerItemId", "kind", "channel", "stream"] + .filter(key => typeof payload[key] === "string").map(key => [key, redactor.redact(payload[key])])), + }); + else deltaStreams.delete(stream); + return result; + }, + /** Settle output in the terminal event, preserving its source receipt identity. */ + settleDeltas(scope: string, payload: Record, wholeTurn: boolean) { + const tails: Array<{ itemId?: string; payload: Record }> = []; + for (const [stream, descriptor] of deltaStreams) { + if (wholeTurn ? !descriptor.scope.startsWith(scope) : descriptor.scope !== scope) continue; + if (!wholeTurn && payload.itemId && descriptor.payload.itemId && payload.itemId !== descriptor.payload.itemId) continue; + const primary = descriptor.paths.find(path => path === `${stream}:.text`) ?? descriptor.paths[0]; + let text = ""; + for (const path of descriptor.paths) { + const tail = redactor.finish(path); + if (path === primary) text = tail; + } + deltaStreams.delete(stream); + if (text) tails.push({ itemId: descriptor.itemId, payload: { ...descriptor.payload, text } }); + } + return tails.length ? { ...payload, outputTails: [ + ...(Array.isArray(payload.outputTails) ? payload.outputTails : []), ...tails, + ] } : payload; + }, + }; + return redactor; +} 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 27007d2b774..681c9332115 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; @@ -23430,7 +23446,7 @@ export function heartbeatService( const currentUserRedactionOptions = await getCurrentUserRedactionOptions(); - const onLog = async (stream: "stdout" | "stderr", chunk: string) => { + const appendIdentityRedactedLog = async (stream: "stdout" | "stderr", chunk: string) => { const sanitizedChunk = compactRunLogChunk( redactCurrentUserText(chunk, currentUserRedactionOptions), ); @@ -23505,6 +23521,8 @@ export function heartbeatService( }, }); }; + const onLog = (stream: "stdout" | "stderr", chunk: string) => + appendIdentityRedactedLog(stream, identityRedactor.chunk(stream, chunk)); if (runScopedMentionedSkillKeys.length > 0) { await onLog( "stdout", @@ -23593,6 +23611,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 +23627,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 +24196,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, @@ -24910,6 +24931,7 @@ export function heartbeatService( process.env, executionWorkspace.cwd, ), + ...buildAgentIdentityEnv(agentIdentity), ...(instructionCopy && isAgentDirectoryCopy(instructionCopy) ? { AGENT_HOME: instructionCopy.executionRoot } : {}), ...(nativeMcpServer ? { @@ -25066,6 +25088,7 @@ export function heartbeatService( legacyAdapterEntered = true; return withAdapterExecutionPhase(executionPhaseContext, "adapter_execution", () => adapter.execute({ getFreshSessionHandoff, + agentIdentity, runId: run.id, agent, runtime: runtimeForAdapter, @@ -25146,6 +25169,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 appendIdentityRedactedLog(stream, tail); + } if (instructionSave) adapterResult.resultJson = { ...adapterResult.resultJson, instructionSave }; adapterResult = applyWorkspaceRestoreFailure(adapterResult); // A returned result can include a failed restore. Keep the workspace @@ -26224,7 +26252,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) @@ -26267,7 +26295,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; @@ -26481,9 +26509,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, @@ -26527,7 +26555,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..88a0da0c2a7 100644 --- a/server/src/services/native-runtime/native-session-executor.test.ts +++ b/server/src/services/native-runtime/native-session-executor.test.ts @@ -18,6 +18,7 @@ import { } from "node:fs/promises"; import { execFileSync } from "node:child_process"; import { tmpdir } from "node:os"; +import { inspect } from "node:util"; import { join } from "node:path"; import { heartbeatRuns, @@ -39,7 +40,7 @@ import { type NativeExecutionInput, type PrpEvent, } from "@paperclipai/paperclip-runner"; -import { createHash } from "node:crypto"; +import { createHash, generateKeyPairSync } from "node:crypto"; import { DatabaseSync } from "node:sqlite"; import { nativeSha256 } from "./canonical.js"; import * as noLaunchProofModule from "./native-maintenance-no-launch.js"; @@ -6271,6 +6272,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; @@ -7594,6 +7612,34 @@ describe("native session bounded recovery", () => { }); }); + it.each(["full", "truncated"])("redacts %s identity from native failure writes, logs, and rethrown errors", async (mode) => { + const privateKeyPem = generateKeyPairSync("ed25519").privateKey.export({ type: "pkcs8", format: "pem" }).toString(); + const keyBody = privateKeyPem.split("\n")[1]; + const failure = new Error(`native provider failed: ${mode === "full" ? privateKeyPem : privateKeyPem.slice(0, 64)}`); + void failure.stack; // Also cover stacks materialized before the catch boundary. + const updates: Array<{ table: unknown; values: Record }> = []; + const logs: string[] = []; + state.execute.mockReset().mockRejectedValueOnce(failure); + state.upsertRecoveryAction.mockReset().mockResolvedValue({}); + const service = vi.spyOn(issueServiceModule, "issueService").mockReturnValue({ + update: vi.fn(async () => ({ status: "blocked", statusVersion: 1 })), + } as unknown as ReturnType); + try { + await expect(executePaperclipNativeSession({ + db: leaseDb(execution, {}, {}, updates), execution, runnerInstanceId: "runner", + runnerEnvironment: { PAPERCLIP_AGENT_PRIVATE_KEY: privateKeyPem }, + onLog: async (_stream, text) => { logs.push(text); }, + })).rejects.toBe(failure); + expect(updates.some(entry => entry.table === nativeRunFinalizations && entry.values.failureDetail)).toBe(true); + for (const output of [inspect(updates.map(entry => entry.values), { depth: null }), logs.join(""), failure.message, failure.stack!]) { + expect(output).not.toContain(keyBody.slice(0, 24)); + expect(output).toContain("***REDACTED***"); + } + } finally { + service.mockRestore(); + } + }); + it("persists actionable operator recovery without an automatic cleanup wake", async () => { const updates: Array<{ table: unknown; values: Record }> = []; diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 981169652ec..0fb5a5ab50b 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"); @@ -7435,6 +7446,10 @@ export async function executePaperclipNativeSession(input: { async function executePaperclipNativeSessionWithinScope( input: Parameters[0], ): Promise { + const identityRedactor = createAgentIdentityRedactor(input.runnerEnvironment?.PAPERCLIP_AGENT_PRIVATE_KEY); + const redactIdentityText = (text: string) => redactSensitiveText( + identityRedactor.chunk("diagnostic", text) + identityRedactor.finish("diagnostic"), + ); if ( input.execution.provider.kind !== "codex" && input.execution.provider.kind !== "opencode" && @@ -7853,6 +7868,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 +8428,7 @@ async function executePaperclipNativeSessionWithinScope( requireSessionCloseBeforeReturn: runnerdBackend !== null || input.instructionWorkingCopy !== undefined, onSessionClosed: input.instructionWorkingCopy?.collectStopped, onCheckpoint: async (snapshot) => { + snapshot = identityRedactor.redact(snapshot); if (warmSessionId !== null && warmConfigDigest !== null) { await persistWarmNativeCheckpoint( input.execution, @@ -8428,7 +8445,7 @@ async function executePaperclipNativeSessionWithinScope( } }, onPostCompletionEnrichmentFailure: async ({ stage, error }) => { - const detail = redactSensitiveText( + const detail = redactIdentityText( error instanceof Error ? error.message : String(error), ).slice(-4_096); await input.onLog?.( @@ -8439,7 +8456,7 @@ async function executePaperclipNativeSessionWithinScope( onSessionQuarantined: async (reason) => { await input.onLog?.( "stderr", - `[paperclip-runner] warm native session quarantined: ${redactSensitiveText(reason).slice(-1_000)}\n`, + `[paperclip-runner] warm native session quarantined: ${redactIdentityText(reason).slice(-1_000)}\n`, ); }, onContinuityBreak: async (continuity) => { @@ -8636,6 +8653,16 @@ async function executePaperclipNativeSessionWithinScope( clearSteeringDeliveries(input.execution.binding.runId); clearNativeRuntimeRequestResolutions(input.execution.binding.runId); } catch (error) { + // Preserve typed failure semantics while removing credentials before any + // persistence, reporting, logging, or rethrow at this boundary. + if (error instanceof Error) { + const originalMessage = error.message; + error.message = redactIdentityText(originalMessage); + if (error.stack) error.stack = redactIdentityText(originalMessage + ? error.stack.split(originalMessage).join(error.message) : error.stack); + } else { + error = identityRedactor.redact(error); + } if (nativeRunsDetachingForRestart.has(input.execution.binding.runId)) { await leaseRenewal.stop().catch(() => undefined); liveQuestions.close(); @@ -8674,7 +8701,7 @@ async function executePaperclipNativeSessionWithinScope( ); const failedAtMs = Date.now(); - const executionFailureMessage = redactSensitiveText( + const executionFailureMessage = redactIdentityText( error instanceof Error ? error.message : String(error), ).slice(-4_096); if (!taskSettleScope) { @@ -8785,11 +8812,10 @@ async function executePaperclipNativeSessionWithinScope( const { exhausted } = recoveryProjection; const integrityFailure = sourceFailureCode === "native_event_replay_conflict"; - const message = - error instanceof Error - ? error.message.slice(0, 2_000) - : String(error).slice(0, 2_000); - const sanitizedStderrTail = redactSensitiveText(message).slice(-4_096); + const message = redactIdentityText( + error instanceof Error ? error.message : String(error), + ).slice(0, 2_000); + const sanitizedStderrTail = redactIdentityText(message).slice(-4_096); // Set inside the transaction only when the write below genuinely // transitions the run into "failed". Read after the transaction // commits, so a rolled-back write never reports a false failure. @@ -8820,7 +8846,7 @@ async function executePaperclipNativeSessionWithinScope( recoveryState: phase === "retryable_failure" ? "resuming_session" : "blocked", failureCode, - failureDetail: { + failureDetail: identityRedactor.redact({ message, originalFailureCode: error instanceof NativeProviderTerminalFailure @@ -8857,7 +8883,7 @@ async function executePaperclipNativeSessionWithinScope( "bootstrap_retry" ? "Retry provider bootstrap on this same run; durable evidence proves no provider session or provider event was created." : "Resume this same run from its exact persisted native provider checkpoint after the retry delay.", - }, + }), nextAttemptAt, recoveryHistory: sql`( select coalesce(jsonb_agg(item order by ordinal), '[]'::jsonb) 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..81bcbfdad86 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,90 @@ 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 }, + }); + let sourceSeq = 2; + for (const [itemId, chunks] of [ + ["halves", [keyBody.slice(0, 31), keyBody.slice(31)]], + ["bytes", [...keyBody]], + ] as const) { + for (const [index, chunk] of chunks.entries()) { + const event: PrpEvent = { + schema: "paperclip.prp.event.v1", sourceEventId: `${runnerId}:${sourceSeq}`, sourceSeq: sourceSeq++, + sourceInstanceId: runnerId, sourceKind: "runner", runId: identity.runId, + normalizedSessionId: identity.sessionId, turnId: "identity-turn", itemId, + eventType: "item.delta", schemaVersion: 1, priority: 1, emittedAt: new Date().toISOString(), + payload: { itemId, kind: "commandExecution", text: chunk, update: { delta: chunk } }, + }; + await port.appendEvent(event); + expect((await port.appendEvent(event)).disposition).toBe("duplicate"); + if (index === 0) await port.appendEvent({ + ...event, sourceEventId: `${runnerId}:${sourceSeq}`, sourceSeq: sourceSeq++, + payload: { itemId: "another-provider-item", kind: "commandExecution", text: "Unrelated output\n", update: { delta: "Unrelated output\n" } }, + }); + } + } + await port.appendEvent({ + schema: "paperclip.prp.event.v1", sourceEventId: `${runnerId}:${sourceSeq}`, sourceSeq: sourceSeq++, + sourceInstanceId: runnerId, sourceKind: "runner", runId: identity.runId, + normalizedSessionId: identity.sessionId, turnId: "tail-turn", itemId: "reasoning-item", + eventType: "item.delta", schemaVersion: 1, priority: 1, emittedAt: new Date().toISOString(), + payload: { kind: "reasoning", text: "Thinking-" }, + }); + const terminal: PrpEvent = { + schema: "paperclip.prp.event.v1", sourceEventId: `${runnerId}:${sourceSeq}`, sourceSeq: sourceSeq++, + sourceInstanceId: runnerId, sourceKind: "runner", runId: identity.runId, + normalizedSessionId: identity.sessionId, turnId: "tail-turn", + eventType: "turn.completed", schemaVersion: 1, priority: 0, emittedAt: new Date().toISOString(), + payload: { status: "completed" }, + }; + await port.appendEvent(terminal); + expect((await port.appendEvent(terminal)).disposition).toBe("duplicate"); + expect(observed.at(-1)?.payload.outputTails).toEqual([{ itemId: "reasoning-item", payload: { kind: "reasoning", text: "-" } }]); + 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***"); + } + for (const itemId of ["halves", "bytes"]) { + const deltas = observed.filter(event => event.payload.itemId === itemId).map(event => event.payload); + expect(deltas.map(delta => delta.text).join("")).toBe("***REDACTED***"); + expect(deltas.map(delta => (delta.update as { delta: string }).delta).join("")).toBe("***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..e41fd522c8e 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 { @@ -39,6 +40,9 @@ export interface PaperclipControlPlaneBinding { controlPlaneSourceInstanceId: string; } +const IDENTITY_DELTA_RETRY_WINDOW = 1_024; +const IDENTITY_DELTA_RETRY_BYTES = 4 * 1024 * 1024; + function isPrpEvent(value: NativeRunEvent | PrpEvent): value is PrpEvent { return "schema" in value && [ "paperclip.prp.event.v1", @@ -71,6 +75,9 @@ 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 #redactedDeltas = new Map(); + #redactedDeltaBytes = 0; readonly #binding: PaperclipControlPlaneBinding; #sessionId: string | null = null; readonly #onCommittedEvent?: (event: PrpEvent) => Promise; @@ -80,11 +87,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 +161,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 +194,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; + let 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"); } @@ -193,6 +204,37 @@ export class PaperclipControlPlanePort implements ControlPlanePort { if (event.sourceInstanceId !== expectedSourceInstanceId) { throw new Error("native_event_source_binding_mismatch"); } + const terminalItem = /^item\.(completed|failed|cancelled)$/.test(event.eventType); + const terminalTurn = /^(turn|session)\.(completed|failed|cancelled|interrupted|closed)$/.test(event.eventType); + if ((event.eventType === "item.delta" || terminalItem || terminalTurn) && this.#identityRedactor.values.length > 0) { + const key = `${event.sourceInstanceId}:${event.sourceEventId}`; + const cached = this.#redactedDeltas.get(key); + const originalSha = nativeSha256(validated.event); + if (cached) { + if (cached.originalSha !== originalSha) throw new Error("native_event_source_payload_conflict"); + event = cached.event; + } else { + const stream = `${event.sourceInstanceId}:${event.turnId}:${event.itemId}`; + event = { ...event, payload: event.eventType === "item.delta" + ? this.#identityRedactor.delta(stream, validated.event.payload, event.itemId) + : this.#identityRedactor.settleDeltas( + terminalTurn ? (event.eventType.startsWith("session.") ? `${event.sourceInstanceId}:` : `${event.sourceInstanceId}:${event.turnId}:`) : stream, + event.payload, terminalTurn, + ), + }; + // Retries must use the same redacted payload without consuming a delta twice. + const bytes = Buffer.byteLength(JSON.stringify(event)); + this.#redactedDeltas.set(key, { event, originalSha, bytes }); + this.#redactedDeltaBytes += bytes; + // Match the bounded runner transcript retry window; large tool output + // also has a byte cap so long runs cannot retain another full transcript. + while (this.#redactedDeltas.size > IDENTITY_DELTA_RETRY_WINDOW || this.#redactedDeltaBytes > IDENTITY_DELTA_RETRY_BYTES) { + const oldest = this.#redactedDeltas.keys().next().value!; + this.#redactedDeltaBytes -= this.#redactedDeltas.get(oldest)!.bytes; + this.#redactedDeltas.delete(oldest); + } + } + } const persisted = await appendHeartbeatRunEvent(this.#db, { companyId: this.#binding.companyId, runId: this.#binding.runId, @@ -264,6 +306,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/components/transcript/native-run-events.test.ts b/ui/src/components/transcript/native-run-events.test.ts index 7fba1ff24ca..7fb01b5b371 100644 --- a/ui/src/components/transcript/native-run-events.test.ts +++ b/ui/src/components/transcript/native-run-events.test.ts @@ -307,6 +307,16 @@ describe("nativeRunEventsToTranscript", () => { expect(transcript.filter((entry) => entry.kind === "run_terminal")).toHaveLength(1); }); + it("preserves buffered output tails when a native turn ends", () => { + const transcript = nativeRunEventsToTranscript([ + event(1, "item.delta", { itemId: "reasoning-1", kind: "reasoning", text: "Thinking" }), + event(2, "turn.completed", { status: "completed", outputTails: [ + { itemId: "reasoning-1", payload: { kind: "reasoning", text: "-" } }, + ] }), + ]); + expect(transcript.filter(entry => entry.kind === "thinking").map(entry => "text" in entry ? entry.text : "").join("")).toBe("Thinking-"); + }); + it("projects provider-neutral messages, tools, usage, and the final reply", () => { const transcript = nativeRunEventsToTranscript([ event(6, "run.result.proposed", runResult("Done safely.")), diff --git a/ui/src/components/transcript/native-run-events.ts b/ui/src/components/transcript/native-run-events.ts index 954a3ce7856..85f5426a16a 100644 --- a/ui/src/components/transcript/native-run-events.ts +++ b/ui/src/components/transcript/native-run-events.ts @@ -612,6 +612,24 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[] if (seenSourceEventIds.has(sourceEventId)) return false; seenSourceEventIds.add(sourceEventId); return true; + }).flatMap((event) => { + const envelope = record(event.payload?.prpEvent); + const payload = record(envelope?.payload); + if (!envelope || envelope.eventType !== event.eventType + || !/^(item|turn|session)\.(completed|failed|cancelled|interrupted|closed)$/.test(event.eventType) + || !Array.isArray(payload?.outputTails)) return [event]; + // Redaction can hold a possible key prefix until the item/turn ends. + // Project its settled tail as display-only deltas before the terminal. + const tails = payload.outputTails.flatMap((value, index): HeartbeatRunEvent[] => { + const tail = record(value); + const tailPayload = record(tail?.payload); + if (!tailPayload || typeof tailPayload.text !== "string") return []; + return [{ ...event, eventType: "item.delta", payload: { prpEvent: { + ...envelope, eventType: "item.delta", itemId: tail?.itemId, + sourceEventId: `${envelope.sourceEventId}:output-tail:${index}`, payload: tailPayload, + } } }]; + }); + return [...tails, event]; }); const hasAcceptedResult = orderedEvents.some( (event) => event.eventType === "run.result.accepted", 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
+
+ )} +