From 0b496c9c03b1e6cee5cb773954823590c7d1d76b Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:04:39 -0500 Subject: [PATCH] feat(secrets): add run-bound agent secret access (#9921) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source control plane people use to manage AI-agent companies. > - Agents already receive selected company secrets through `env.*` bindings at run launch, but environment injection is ambient, long-lived, and not suitable for every secret consumer. > - The existing binding and secret-access-event models already provide company-scoped authorization and per-resolution audit seams. > - Agents need an explicit way to discover only the secrets granted to them and fetch a value on demand without exposing the wider company catalog. > - That capability must remain run-bound, preserve low-trust token carve-outs, and make every value read visible in both security and operator audit trails. > - This pull request adds an `access.*` delivery namespace, two run-bound agent routes, dual audit logging, documentation, and an operator grants editor. > - The benefit is least-privilege, revocable, auditable secret access while preserving existing env injection behavior. ## Linked Issues or Issue Description No pre-existing public issue. Related work: - Refs #9797 — existing in-sheet agent access UI that this PR extends to distinguish env and API delivery. - Refs #9918 — complementary searchable-agent picker improvement for the same secrets sheet. - Refs #9530 — related company-wide metadata catalog proposal; this PR intentionally exposes only the authenticated run's granted aliases and values. **Problem / motivation:** Agents can currently consume secrets only through process environment injection. This keeps values resident for the run, does not support on-demand consumers, and cannot provide a discrete operator-visible activity event for each agent-initiated read. **Proposed solution:** Treat `company_secret_bindings` as the source of truth for agent secret grants. Keep `env.KEY` as env delivery and add `access.ALIAS` for API-only delivery; an env binding also implies read access because the value is already present in the agent process. Add run-bound list/fetch endpoints that derive scope from the authenticated heartbeat run and never accept caller-selected overlays. **Alternatives considered:** A company-wide agent-readable catalog was rejected for this value path because it increases reconnaissance and does not prove a per-secret grant. Reusing the ephemeral environment-probe resolver was rejected because it lacks binding enforcement. Approval-gated reads and user-scoped secrets remain deferred beyond v1. **Roadmap alignment:** This extends the completed **Secrets Manager with per-agent access** roadmap capability from launch-time env injection to explicit run-bound API delivery without duplicating a separate planned initiative. ## What Changed - Added `access.*` agent binding validation and a dedicated run-bound resolver that combines `secrets:read` authorization with binding-context enforcement. - Added `GET /api/agents/me/secrets` for minimal granted metadata and `POST /api/agents/me/secrets/:key/value` for on-demand value fetches with `Cache-Control: no-store`. - Preserved the existing denials for low-trust review agents, task-bridge credentials, and skill-test tokens; standard long-lived agent API keys cannot call the run-bound routes. - Added dual audit behavior: value attempts write `secret_access_events` and `activity_log` (`secret.value.read`), while metadata listing writes the lighter `secret.access.listed` activity event. - Kept env compatibility: `env.*` remains injected at launch and also implies API read for the same bound agent; `access.*` never becomes an environment variable. - Added the agent-settings **Secret access** editor plus delivery-mode/alias surfacing on the Secrets page, with focused UI tests and tokenized layout styles. - Updated OpenAPI, shared types, agent-facing skill documentation, and API reference documentation. ### UI Screenshots P3 produced and reviewed three screenshots using mock data; images are intentionally not committed to the repository: - `secret-access-editor.png` — agent settings grant editor. - `secret-access-light.png` — Secrets-page delivery surfacing in light mode. - `secret-access-dark.png` — Secrets-page delivery surfacing in dark mode. The source attachments are retained with the implementation task and linked in the internal handoff; the public page publisher was unavailable in the PR-prep runtime. ## Verification - `pnpm exec vitest run server/src/__tests__/agent-secrets-routes.test.ts server/src/__tests__/secrets-service.test.ts server/src/__tests__/secrets-routes.test.ts ui/src/lib/secret-delivery.test.ts ui/src/components/AgentSecretAccessEditor.test.tsx` — 5 files, 122 tests passed. - Security follow-up: `pnpm exec vitest run server/src/__tests__/agent-secrets-routes.test.ts server/src/__tests__/secrets-service.test.ts` — 2 files, 73 tests passed after active-run and version-consistency fixes. - Final-head CI: all feature, typecheck, build, e2e, security, and review gates pass; `General tests (server (1/3))` remains red after one rerun because unrelated `heartbeat-retry-scheduling.test.ts` cleanup deletes `heartbeat_runs` before referenced `activity_log` rows. - `pnpm --filter @paperclipai/server typecheck` — passed. - `pnpm --filter @paperclipai/shared typecheck` — passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm check:token-gates` — feature-local arbitrary-value violations fixed; command still reports five unchanged `#9627` literals outside this PR. - End-to-end QA passed all eight acceptance criteria: grant/list, fetch, dual audit, env-implies-read, denial matrix, revocation, UI rendering, and env-injection regression. Evidence: https://github.com/paperclipai/paperclip/pull/9921#issuecomment-5027455492 - Security review returned PASS-with-required-changes; the implementation uses the required dedicated binding-enforcing resolver, run-bound JWT restriction, run-derived overlays, minimal metadata, and a resolver redaction-registration hook. Evidence: https://github.com/paperclipai/paperclip/pull/9921#issuecomment-5027455382 ## Risks - A compromised agent can exfiltrate any secret explicitly granted to it; explicit company-scoped/run-scoped grants, revocation, and audit reduce but cannot remove that inherent capability risk. - The resolver invokes a redaction-registration hook before returning values, but the current route has no persistent cross-request per-run redaction registry. Paperclip-owned later comments/events therefore cannot yet guarantee automatic scrubbing of a deliberately copied fetched value; QA classified this as non-blocking residual hardening. - Audit-event insertion currently fails open if the security-event insert itself fails; the operator activity event provides partial redundancy, but a future hardening change should define fail-closed behavior for value delivery. - This PR overlaps `ui/src/pages/Secrets.tsx` with #9918 and may require a straightforward rebase after that PR moves. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, `gpt-5.3-codex`, with reasoning, repository tool use, terminal execution, Paperclip API access, and GitHub CLI capabilities. Context-window size is not exposed by the runtime. - Anthropic Claude Opus 4.8 with 1M context and tool use assisted with the UI implementation commit. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip Co-authored-by: Claude Opus 4.8 (1M context) --- docs/api/secrets.md | 57 ++- packages/shared/src/types/secrets.ts | 2 +- .../__tests__/agent-secrets-routes.test.ts | 245 ++++++++++++ .../heartbeat-retry-scheduling.test.ts | 22 +- server/src/__tests__/secrets-routes.test.ts | 3 + server/src/__tests__/secrets-service.test.ts | 237 ++++++++++++ server/src/routes/openapi.ts | 41 +++ server/src/routes/secrets.ts | 60 +++ server/src/services/secrets.ts | 340 ++++++++++++++++- skills/paperclip/SKILL.md | 26 ++ skills/paperclip/references/api-reference.md | 37 ++ ui/src/components/AgentConfigForm.tsx | 36 ++ .../AgentSecretAccessEditor.test.tsx | 163 ++++++++ ui/src/components/AgentSecretAccessEditor.tsx | 348 ++++++++++++++++++ ui/src/components/agent-config-primitives.tsx | 2 + ui/src/index.css | 1 + ui/src/lib/secret-delivery.test.ts | 56 +++ ui/src/lib/secret-delivery.ts | 83 +++++ ui/src/pages/Secrets.tsx | 164 ++++++--- 19 files changed, 1870 insertions(+), 53 deletions(-) create mode 100644 server/src/__tests__/agent-secrets-routes.test.ts create mode 100644 ui/src/components/AgentSecretAccessEditor.test.tsx create mode 100644 ui/src/components/AgentSecretAccessEditor.tsx create mode 100644 ui/src/lib/secret-delivery.test.ts create mode 100644 ui/src/lib/secret-delivery.ts diff --git a/docs/api/secrets.md b/docs/api/secrets.md index f1aad3b2a3..0369922928 100644 --- a/docs/api/secrets.md +++ b/docs/api/secrets.md @@ -3,7 +3,62 @@ title: Secrets summary: Secrets CRUD --- -Manage encrypted secrets that agents reference in their environment configuration. +Manage encrypted secrets that agents receive through environment bindings or fetch on demand. + +## Agent List and Fetch + +These routes require the current run-bound agent JWT. They are not available to +long-lived agent keys, low-trust review agents, task-bridge keys, or skill-test +tokens. + +List the secrets accessible to the current run without materializing values: + +``` +GET /api/agents/me/secrets +``` + +```json +{ + "secrets": [ + { + "key": "github_token", + "name": "GitHub token", + "description": null, + "delivery": "env", + "projectionClass": "unclassified", + "latestVersion": 2, + "versionSelector": "latest", + "resolvedVersion": 2 + } + ] +} +``` + +`delivery` is `env`, `api`, or `both`. The list never returns values, secret +IDs, binding IDs, or config paths. An `env.*` binding implies read access through +this API; an `access.*` binding grants API access without environment injection. + +Fetch a value only when it is needed. The request has no body and the response +uses `Cache-Control: no-store`: + +``` +POST /api/agents/me/secrets/github_token/value +``` + +```json +{ + "key": "github_token", + "value": "decrypted-secret-value", + "version": 2 +} +``` + +Prefer env injection when the adapter or its child processes need the value on +every run. Prefer on-demand fetch for values used only on some runs, large or +structured values, or skills and tools that do not inherit adapter env. Every +successful or failed value fetch is audited in both `secret_access_events` and +`activity_log`; agents must not log or paste fetched values into issues, +comments, or documents. ## List Secrets diff --git a/packages/shared/src/types/secrets.ts b/packages/shared/src/types/secrets.ts index 03fcbd77d6..3d754476da 100644 --- a/packages/shared/src/types/secrets.ts +++ b/packages/shared/src/types/secrets.ts @@ -290,7 +290,7 @@ export interface SecretAccessEvent { credentialSubjectId: string | null; actorType: "agent" | "user" | "system" | "plugin"; actorId: string | null; - consumerType: SecretBindingTargetType | "plugin_worker"; + consumerType: SecretBindingTargetType | "agent_api" | "plugin_worker"; consumerId: string; configPath: string | null; issueId: string | null; diff --git a/server/src/__tests__/agent-secrets-routes.test.ts b/server/src/__tests__/agent-secrets-routes.test.ts new file mode 100644 index 0000000000..bd229f7921 --- /dev/null +++ b/server/src/__tests__/agent-secrets-routes.test.ts @@ -0,0 +1,245 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import express from "express"; +import request from "supertest"; +import { eq } from "drizzle-orm"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { + activityLog, + agents, + companies, + companySecretBindings, + companySecretProviderConfigs, + companySecretVersions, + companySecrets, + createDb, + heartbeatRuns, + secretAccessEvents, +} from "@paperclipai/db"; +import { LOW_TRUST_REVIEW_PRESET, type AgentApiKeyScope } from "@paperclipai/shared"; +import { errorHandler } from "../middleware/error-handler.js"; +import { secretRoutes } from "../routes/secrets.js"; +import { secretService } from "../services/secrets.js"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("agent secret routes", () => { + let stopDb: (() => Promise) | null = null; + let db!: ReturnType; + const previousKeyFile = process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + const secretsTmpDir = path.join(os.tmpdir(), `paperclip-agent-secret-routes-${randomUUID()}`); + + beforeAll(async () => { + mkdirSync(secretsTmpDir, { recursive: true }); + process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = path.join(secretsTmpDir, "master.key"); + const started = await startEmbeddedPostgresTestDatabase("agent-secret-routes"); + stopDb = started.cleanup; + db = createDb(started.connectionString); + }); + + afterEach(async () => { + await db.delete(activityLog); + await db.delete(secretAccessEvents); + await db.delete(companySecretBindings); + await db.delete(companySecretVersions); + await db.delete(companySecrets); + await db.delete(companySecretProviderConfigs); + await db.delete(heartbeatRuns); + await db.delete(agents); + await db.delete(companies); + }); + + afterAll(async () => { + await stopDb?.(); + if (previousKeyFile === undefined) delete process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE; + else process.env.PAPERCLIP_SECRETS_MASTER_KEY_FILE = previousKeyFile; + rmSync(secretsTmpDir, { recursive: true, force: true }); + }); + + async function seedAgentRun(permissions: Record = {}) { + const companyId = randomUUID(); + const agentId = randomUUID(); + const heartbeatRunId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Agent secret routes", + issuePrefix: `S${companyId.slice(0, 7)}`.toUpperCase(), + status: "active", + }); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Secret reader", + role: "engineer", + adapterType: "codex_local", + adapterConfig: {}, + permissions, + status: "idle", + }); + await db.insert(heartbeatRuns).values({ + id: heartbeatRunId, + companyId, + agentId, + status: "running", + contextSnapshot: {}, + }); + return { companyId, agentId, heartbeatRunId }; + } + + function createApp( + fixture: Awaited>, + keyScope: AgentApiKeyScope = { kind: "standard" }, + source: "agent_jwt" | "agent_key" = "agent_jwt", + ) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.actor = { + type: "agent", + agentId: fixture.agentId, + companyId: fixture.companyId, + runId: fixture.heartbeatRunId, + keyScope, + keyId: source === "agent_key" ? randomUUID() : undefined, + source, + }; + next(); + }); + app.use("/api", secretRoutes(db)); + app.use(errorHandler); + return app; + } + + it("lists metadata only, reads env and access grants, and audits success and failure", async () => { + const fixture = await seedAgentRun(); + const svc = secretService(db); + const envSecret = await svc.create(fixture.companyId, { + key: "ENV_ONLY_KEY", + name: "Env only", + description: "Injected and API-readable", + provider: "local_encrypted", + value: "env-secret-value", + }); + const apiSecret = await svc.create(fixture.companyId, { + key: "API_ONLY_KEY", + name: "API only", + provider: "local_encrypted", + value: "api-secret-value", + }); + const unboundSecret = await svc.create(fixture.companyId, { + key: "UNBOUND_KEY", + name: "Unbound", + provider: "local_encrypted", + value: "unbound-secret-value", + }); + const projectSecret = await svc.create(fixture.companyId, { + key: "PROJECT_KEY", + name: "Project layer", + provider: "local_encrypted", + value: "project-secret-value", + }); + await svc.createBinding({ + companyId: fixture.companyId, + secretId: envSecret.id, + targetType: "agent", + targetId: fixture.agentId, + configPath: "env.ENV_ONLY_KEY", + }); + await svc.createBinding({ + companyId: fixture.companyId, + secretId: apiSecret.id, + targetType: "agent", + targetId: fixture.agentId, + configPath: "access.API_ONLY_KEY", + projectionClass: "class_2_runtime_only", + }); + const projectBinding = await svc.createBinding({ + companyId: fixture.companyId, + secretId: projectSecret.id, + targetType: "project", + targetId: randomUUID(), + configPath: "env.PROJECT_KEY", + }); + await db.update(heartbeatRuns).set({ + contextSnapshot: { + paperclipSecrets: { + manifest: [{ + bindingId: projectBinding.id, + secretId: projectSecret.id, + configPath: projectBinding.configPath, + }], + }, + }, + }).where(eq(heartbeatRuns.id, fixture.heartbeatRunId)); + + const list = await request(createApp(fixture)).get("/api/agents/me/secrets"); + expect(list.status).toBe(200); + expect(list.body.secrets).toEqual([ + expect.objectContaining({ key: "api_only_key", delivery: "api", projectionClass: "class_2_runtime_only" }), + expect.objectContaining({ key: "env_only_key", delivery: "env" }), + expect.objectContaining({ key: "project_key", delivery: "env" }), + ]); + expect(JSON.stringify(list.body)).not.toContain("secret-value"); + expect(await db.select().from(secretAccessEvents)).toEqual([]); + expect(await db.select().from(activityLog)).toEqual([ + expect.objectContaining({ action: "secret.access.listed", runId: fixture.heartbeatRunId }), + ]); + + const fetched = await request(createApp(fixture)).post("/api/agents/me/secrets/env_only_key/value"); + expect(fetched.status).toBe(200); + expect(fetched.headers["cache-control"]).toBe("no-store"); + expect(fetched.body).toEqual({ key: "env_only_key", value: "env-secret-value", version: 1 }); + expect(await db.select().from(secretAccessEvents)).toEqual([ + expect.objectContaining({ secretId: envSecret.id, outcome: "success", consumerType: "agent_api" }), + ]); + expect(await db.select().from(activityLog)).toEqual(expect.arrayContaining([ + expect.objectContaining({ action: "secret.value.read", entityId: envSecret.id }), + ])); + + const projectFetched = await request(createApp(fixture)).post("/api/agents/me/secrets/project_key/value"); + expect(projectFetched.status).toBe(200); + expect(projectFetched.body).toEqual({ key: "project_key", value: "project-secret-value", version: 1 }); + expect(await db.select().from(secretAccessEvents)).toEqual(expect.arrayContaining([ + expect.objectContaining({ secretId: projectSecret.id, outcome: "success", consumerType: "agent_api" }), + ])); + + const denied = await request(createApp(fixture)).post("/api/agents/me/secrets/unbound_key/value"); + expect(denied.status).toBe(403); + expect(await db.select().from(secretAccessEvents)).toEqual(expect.arrayContaining([ + expect.objectContaining({ secretId: unboundSecret.id, outcome: "failure", errorCode: "binding_missing" }), + ])); + expect(await db.select().from(activityLog)).toEqual(expect.arrayContaining([ + expect.objectContaining({ action: "secret.value.read", entityId: unboundSecret.id }), + ])); + }); + + it("denies low-trust, task-bridge, and skill-test callers on both routes", async () => { + const lowTrust = await seedAgentRun({ + trustPreset: LOW_TRUST_REVIEW_PRESET, + authorizationPolicy: { trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET, projectIds: [randomUUID()] } }, + }); + const standard = await seedAgentRun(); + const cases = [ + { name: "low trust", fixture: lowTrust, scope: { kind: "standard" } as const, source: "agent_jwt" as const }, + { name: "task bridge", fixture: standard, scope: { kind: "task_bridge", parentIssueId: randomUUID() } as const, source: "agent_key" as const }, + { name: "skill test", fixture: standard, scope: { kind: "skill_test", issueId: randomUUID() } as const, source: "agent_jwt" as const }, + ]; + for (const testCase of cases) { + expect( + (await request(createApp(testCase.fixture, testCase.scope, testCase.source)).get("/api/agents/me/secrets")).status, + `${testCase.name} list`, + ).toBe(403); + expect( + (await request(createApp(testCase.fixture, testCase.scope, testCase.source)).post("/api/agents/me/secrets/ANY/value")).status, + `${testCase.name} fetch`, + ).toBe(403); + } + }); +}); diff --git a/server/src/__tests__/heartbeat-retry-scheduling.test.ts b/server/src/__tests__/heartbeat-retry-scheduling.test.ts index a4bb3c05b2..a4f43a1609 100644 --- a/server/src/__tests__/heartbeat-retry-scheduling.test.ts +++ b/server/src/__tests__/heartbeat-retry-scheduling.test.ts @@ -100,13 +100,33 @@ describeEmbeddedPostgres("heartbeat bounded retry scheduling", () => { }); async function cleanupRetryFixture() { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await cleanupRetryFixtureOnce(); + return; + } catch (error) { + if (attempt === 4) throw error; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + } + + async function cleanupHeartbeatRunDependents() { + await db.delete(heartbeatRunEvents); + await db.delete(activityLog); + await new Promise((resolve) => setTimeout(resolve, 25)); + await db.delete(heartbeatRunEvents); + await db.delete(activityLog); + } + + async function cleanupRetryFixtureOnce() { await db.delete(activityLog); await db.delete(environmentLeases); await db.delete(issueRelations); await db.delete(issues); await db.delete(executionWorkspaces); await db.delete(projects); - await db.delete(heartbeatRunEvents); + await cleanupHeartbeatRunDependents(); await db.delete(heartbeatRuns); await db.delete(agentWakeupRequests); await db.delete(agentRuntimeState); diff --git a/server/src/__tests__/secrets-routes.test.ts b/server/src/__tests__/secrets-routes.test.ts index aa6832ba11..11ab3fbd73 100644 --- a/server/src/__tests__/secrets-routes.test.ts +++ b/server/src/__tests__/secrets-routes.test.ts @@ -18,6 +18,7 @@ const mockSecretService = vi.hoisted(() => ({ setDefaultProviderConfig: vi.fn(), checkProviderConfigHealth: vi.fn(), getById: vi.fn(), + getByKey: vi.fn(), create: vi.fn(), rotate: vi.fn(), update: vi.fn(), @@ -36,6 +37,8 @@ const mockSecretService = vi.hoisted(() => ({ importRemoteSecrets: vi.fn(), listBindingReferences: vi.fn(), listAccessEvents: vi.fn(), + listAgentSecretAccess: vi.fn(), + resolveSecretValueForAgentAccess: vi.fn(), })); const mockLogActivity = vi.hoisted(() => vi.fn()); diff --git a/server/src/__tests__/secrets-service.test.ts b/server/src/__tests__/secrets-service.test.ts index 56ffef3ad6..b025ca3779 100644 --- a/server/src/__tests__/secrets-service.test.ts +++ b/server/src/__tests__/secrets-service.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { eq } from "drizzle-orm"; import { + activityLog, agents, companies, companyMemberships, @@ -13,10 +14,12 @@ import { companySecretVersions, companySecrets, createDb, + heartbeatRuns, secretAccessEvents, userSecretDeclarations, userSecretDefinitions, } from "@paperclipai/db"; +import { LOW_TRUST_REVIEW_PRESET } from "@paperclipai/shared"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; import { awsSecretsManagerProvider } from "../secrets/aws-secrets-manager-provider.js"; import { localEncryptedProvider } from "../secrets/local-encrypted-provider.js"; @@ -48,6 +51,7 @@ describeEmbeddedPostgres("secretService", () => { afterEach(async () => { vi.restoreAllMocks(); + await db.delete(activityLog); await db.delete(secretAccessEvents); await db.delete(userSecretDeclarations); await db.delete(companySecretBindings); @@ -56,6 +60,7 @@ describeEmbeddedPostgres("secretService", () => { await db.delete(userSecretDefinitions); await db.delete(companySecretProviderConfigs); await db.delete(companyMemberships); + await db.delete(heartbeatRuns); await db.delete(agents); await db.delete(companies); }); @@ -99,6 +104,33 @@ describeEmbeddedPostgres("secretService", () => { }); } + async function seedAgentRun(companyId: string, permissions: Record = {}) { + const agentId = randomUUID(); + await db.insert(agents).values({ + id: agentId, + companyId, + name: "Secret reader", + role: "engineer", + adapterType: "codex_local", + adapterConfig: {}, + permissions, + status: "idle", + createdAt: new Date(), + updatedAt: new Date(), + }); + const heartbeatRunId = randomUUID(); + await db.insert(heartbeatRuns).values({ + id: heartbeatRunId, + companyId, + agentId, + status: "running", + startedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }); + return { agentId, heartbeatRunId }; + } + it("rejects cross-company secret references during env normalization", async () => { const companyA = await seedCompany("A"); const companyB = await seedCompany("B"); @@ -149,6 +181,211 @@ describeEmbeddedPostgres("secretService", () => { ).rejects.toThrow(/already exists/i); }); + it("validates the access namespace as agent-only with env-style aliases", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const secret = await svc.create(companyId, { + name: `access-validation-${randomUUID()}`, + provider: "local_encrypted", + value: "runtime-secret", + }); + + await expect(svc.createBinding({ + companyId, + secretId: secret.id, + targetType: "project", + targetId: randomUUID(), + configPath: "access.API_KEY", + })).rejects.toThrow(/must target an agent/i); + + await expect(svc.createBinding({ + companyId, + secretId: secret.id, + targetType: "agent", + targetId: randomUUID(), + configPath: "access.invalid-alias", + })).rejects.toThrow(/invalid agent secret access alias/i); + }); + + it("resolves env and access bindings through the run-bound agent resolver with dual audit", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const { agentId, heartbeatRunId } = await seedAgentRun(companyId); + const secret = await svc.create(companyId, { + name: `agent-read-${randomUUID()}`, + provider: "local_encrypted", + value: "runtime-secret", + }); + await svc.createBinding({ + companyId, + secretId: secret.id, + targetType: "agent", + targetId: agentId, + configPath: "access.API_KEY", + }); + await svc.createBinding({ + companyId, + secretId: secret.id, + targetType: "agent", + targetId: agentId, + configPath: "env.API_KEY", + }); + const redactedValues: string[] = []; + + for (const configPath of ["access.API_KEY", "env.API_KEY"]) { + await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", { + agentId, + configPath, + actorSource: "agent_jwt", + heartbeatRunId, + registerForRedaction: (value) => redactedValues.push(value), + })).resolves.toEqual({ value: "runtime-secret", version: 1 }); + } + + expect(redactedValues).toEqual(["runtime-secret", "runtime-secret"]); + const events = await svc.listAccessEvents(companyId, secret.id); + expect(events).toHaveLength(2); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + consumerType: "agent_api", + consumerId: agentId, + configPath: "access.API_KEY", + actorType: "agent", + actorId: agentId, + heartbeatRunId, + outcome: "success", + }), + expect.objectContaining({ + consumerType: "agent_api", + consumerId: agentId, + configPath: "env.API_KEY", + outcome: "success", + }), + ])); + const activities = await db + .select() + .from(activityLog) + .where(eq(activityLog.entityId, secret.id)); + expect(activities).toHaveLength(2); + expect(activities.every((entry) => entry.action === "secret.value.read")).toBe(true); + expect(activities.every((entry) => entry.runId === heartbeatRunId)).toBe(true); + expect(JSON.stringify([...events, ...activities])).not.toContain("runtime-secret"); + }); + + it("rejects long-lived, mismatched-run, and unbound agent secret reads", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const { agentId, heartbeatRunId } = await seedAgentRun(companyId); + const secret = await svc.create(companyId, { + name: `agent-read-denied-${randomUUID()}`, + provider: "local_encrypted", + value: "runtime-secret", + }); + await svc.createBinding({ + companyId, + secretId: secret.id, + targetType: "agent", + targetId: agentId, + configPath: "access.GRANTED", + }); + const registerForRedaction = vi.fn(); + + await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", { + agentId, + configPath: "access.API_KEY", + actorSource: "agent_key", + heartbeatRunId, + registerForRedaction, + })).rejects.toThrow(/run-bound agent token/i); + + await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", { + agentId, + configPath: "access.GRANTED", + actorSource: "agent_jwt", + keyScope: { kind: "skill_test", issueId: randomUUID() }, + heartbeatRunId, + registerForRedaction, + })).rejects.toThrow(/skill-test.*secret/i); + + await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", { + agentId, + configPath: "access.API_KEY", + actorSource: "agent_jwt", + heartbeatRunId: randomUUID(), + registerForRedaction, + })).rejects.toThrow(/verified heartbeat run/i); + + await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", { + agentId, + configPath: "access.API_KEY", + actorSource: "agent_jwt", + heartbeatRunId, + registerForRedaction, + })).rejects.toThrow(/not granted/i); + + await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, heartbeatRunId)); + await expect(svc.listAgentSecretAccess(companyId, { + agentId, + actorSource: "agent_jwt", + heartbeatRunId, + })).rejects.toThrow(/verified heartbeat run/i); + await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", { + agentId, + configPath: "access.GRANTED", + actorSource: "agent_jwt", + heartbeatRunId, + registerForRedaction, + })).rejects.toThrow(/verified heartbeat run/i); + + expect(registerForRedaction).not.toHaveBeenCalled(); + const events = await svc.listAccessEvents(companyId, secret.id); + expect(events).toEqual([ + expect.objectContaining({ + consumerType: "agent_api", + consumerId: agentId, + configPath: "access.API_KEY", + outcome: "failure", + errorCode: "binding_missing", + }), + ]); + }); + + it("preserves low-trust authorization denial for agent secret reads", async () => { + const companyId = await seedCompany(); + const svc = secretService(db); + const { agentId, heartbeatRunId } = await seedAgentRun(companyId, { + trustPreset: LOW_TRUST_REVIEW_PRESET, + authorizationPolicy: { + trustBoundary: { + mode: LOW_TRUST_REVIEW_PRESET, + projectIds: [randomUUID()], + }, + }, + }); + const secret = await svc.create(companyId, { + name: `low-trust-agent-read-${randomUUID()}`, + provider: "local_encrypted", + value: "runtime-secret", + }); + await svc.createBinding({ + companyId, + secretId: secret.id, + targetType: "agent", + targetId: agentId, + configPath: "access.API_KEY", + }); + + await expect(svc.resolveSecretValueForAgentAccess(companyId, secret.id, "latest", { + agentId, + configPath: "access.API_KEY", + actorSource: "agent_jwt", + heartbeatRunId, + registerForRedaction: vi.fn(), + })).rejects.toThrow(/low[_-]trust.*secrets:read/i); + + expect(await svc.listAccessEvents(companyId, secret.id)).toEqual([]); + }); + it("syncs top-level secret refs idempotently", async () => { const companyId = await seedCompany(); const svc = secretService(db); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index ce2a05b685..b2ec715500 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -1503,6 +1503,47 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized }, }); +const AgentSecretListResponseSchema = z.object({ + secrets: z.array(z.object({ + key: z.string(), + name: z.string(), + description: z.string().nullable(), + delivery: z.enum(["env", "api", "both"]), + projectionClass: z.string(), + latestVersion: z.number().int().nonnegative(), + versionSelector: z.union([z.literal("latest"), z.number().int().positive()]), + resolvedVersion: z.number().int().positive(), + })), +}); + +registry.registerPath({ + method: "get", + path: "/api/agents/me/secrets", + tags: ["secrets"], + summary: "List secrets accessible to the current agent run", + responses: { + 200: { description: "Accessible secret metadata", content: { "application/json": { schema: AgentSecretListResponseSchema } } }, + 401: r.unauthorized, + 403: r.forbidden, + }, +}); + +registry.registerPath({ + method: "post", + path: "/api/agents/me/secrets/{key}/value", + tags: ["secrets"], + summary: "Fetch one secret value for the current agent run", + request: { params: z.object({ key: z.string() }) }, + responses: { + 200: { + description: "Decrypted secret value", + content: { "application/json": { schema: z.object({ key: z.string(), value: z.string(), version: z.number().int().positive() }) } }, + }, + 401: r.unauthorized, + 403: r.forbidden, + }, +}); + registry.registerPath({ method: "post", path: "/api/agents/me/connections/{connectionId}/token", diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index 884f626c83..9caf82ff25 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -60,6 +60,66 @@ export function secretRoutes(db: Db) { const svc = secretService(db); const defaultProvider = getConfiguredSecretProvider(); + function agentSecretContext(req: Parameters[0]) { + if (req.actor.type !== "agent" || !req.actor.agentId || !req.actor.companyId || !req.actor.runId) { + throw forbidden("Run-bound agent authentication required"); + } + return { + companyId: req.actor.companyId, + agentId: req.actor.agentId, + actorSource: req.actor.source === "agent_jwt" ? "agent_jwt" as const : "agent_key" as const, + keyId: req.actor.keyId ?? null, + keyScope: req.actor.keyScope ?? null, + heartbeatRunId: req.actor.runId, + responsibleUserId: req.actor.onBehalfOfUserId ?? null, + }; + } + + router.get("/agents/me/secrets", async (req, res) => { + const context = agentSecretContext(req); + const secrets = await svc.listAgentSecretAccess(context.companyId, context); + await logActivity(db, { + companyId: context.companyId, + actorType: "agent", + actorId: context.agentId, + action: "secret.access.listed", + entityType: "agent", + entityId: context.agentId, + agentId: context.agentId, + runId: context.heartbeatRunId, + details: { count: secrets.length }, + }); + res.json({ + secrets: secrets.map(({ secretId: _secretId, bindingId: _bindingId, configPath: _configPath, ...secret }) => secret), + }); + }); + + router.post("/agents/me/secrets/:key/value", async (req, res) => { + const context = agentSecretContext(req); + const available = await svc.listAgentSecretAccess(context.companyId, context); + const secret = available.find((entry) => entry.key === req.params.key); + const unresolvedSecret = secret ? null : await svc.getByKey(context.companyId, req.params.key); + if (!secret && !unresolvedSecret) throw forbidden("Secret access is not granted for this agent"); + const resolution = await svc.resolveSecretValueForAgentAccess( + context.companyId, + secret?.secretId ?? unresolvedSecret!.id, + secret?.versionSelector ?? "latest", + { + ...context, + configPath: secret?.configPath ?? `access.${req.params.key}`, + bindingId: secret?.bindingId ?? null, + issueId: null, + registerForRedaction: () => undefined, + }, + ); + res.set("Cache-Control", "no-store"); + res.json({ + key: secret?.key ?? unresolvedSecret!.key, + value: resolution.value, + version: resolution.version, + }); + }); + router.get("/companies/:companyId/secret-providers", (req, res) => { assertBoard(req); const companyId = req.params.companyId as string; diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 791f58eba1..83362222d8 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -18,6 +18,7 @@ import { userSecretDefinitions, } from "@paperclipai/db"; import type { + AgentApiKeyScope, AgentEnvConfig, CompanySecretBindingTarget, EnvBinding, @@ -62,8 +63,10 @@ import type { import { isSecretProviderClientError } from "../secrets/types.js"; import { authorizationDeniedDetails, authorizationService } from "./authorization.js"; import { findActiveServerAdapter } from "../adapters/index.js"; +import { logActivity } from "./activity-log.js"; const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; +const AGENT_ACCESS_CONFIG_PATH_PREFIX = "access."; const SENSITIVE_ENV_KEY_RE = /(api[-_]?key|access[-_]?token|auth(?:_?token)?|authorization|bearer|secret|passwd|password|credential|jwt|private[-_]?key|cookie|connectionstring)/i; const REDACTED_SENTINEL = "***REDACTED***"; @@ -394,7 +397,7 @@ type CanonicalEnvBinding = allowMissingOverride: boolean; }; -type SecretAccessConsumerType = SecretBindingTargetType | "plugin_worker"; +type SecretAccessConsumerType = SecretBindingTargetType | "agent_api" | "plugin_worker"; type SecretConsumerContext = { consumerType: SecretAccessConsumerType; @@ -420,6 +423,33 @@ type SecretResolutionOptions = { allowUserSecretScope?: boolean; }; +export type AgentSecretReadContext = { + agentId: string; + configPath: string; + bindingId?: string | null; + actorSource: "agent_jwt" | "agent_key"; + keyId?: string | null; + keyScope?: AgentApiKeyScope | null; + heartbeatRunId: string; + issueId?: string | null; + responsibleUserId?: string | null; + registerForRedaction: (value: string) => void | Promise; +}; + +export type AgentSecretAccessEntry = { + secretId: string; + bindingId: string; + configPath: string; + key: string; + name: string; + description: string | null; + delivery: "env" | "api" | "both"; + projectionClass: SecretProjectionClass; + latestVersion: number; + versionSelector: SecretVersionSelector; + resolvedVersion: number; +}; + type ResolveAdapterConfigForRuntimeOptions = { adapterType?: string | null; skipUserSecrets?: boolean; @@ -454,7 +484,9 @@ export type MissingRuntimeBinding = { }; function missingRuntimeConsumerType(consumerType: SecretAccessConsumerType): SecretBindingTargetType { - return consumerType === "plugin_worker" ? "plugin" : consumerType; + if (consumerType === "plugin_worker") return "plugin"; + if (consumerType === "agent_api") return "agent"; + return consumerType; } type RuntimeSecretResolution = { @@ -595,6 +627,20 @@ function secretResolutionErrorCode(error: unknown): SecretResolutionErrorCode { return "provider_error"; } +function assertSecretBindingConfigPath(input: { + targetType: SecretBindingTargetType; + configPath: string; +}) { + if (!input.configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) return; + if (input.targetType !== "agent") { + throw unprocessable("API-only secret access bindings must target an agent"); + } + const alias = input.configPath.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length); + if (!ENV_KEY_RE.test(alias)) { + throw unprocessable(`Invalid agent secret access alias: ${alias || "(empty)"}`); + } +} + function missingUserSecretDefinitionRuntimeBinding( entry: { key: string; @@ -670,6 +716,19 @@ export function secretService(db: Db) { .then((rows) => rows[0] ?? null); } + async function getByKey(companyId: string, key: string) { + return db + .select() + .from(companySecrets) + .where(and( + eq(companySecrets.companyId, companyId), + eq(companySecrets.key, key), + eq(companySecrets.scope, "company"), + ne(companySecrets.status, "deleted"), + )) + .then((rows) => rows[0] ?? null); + } + async function getUserSecretDefinitionById( companyId: string, definitionId: string, @@ -1174,6 +1233,278 @@ export function secretService(db: Db) { })).value; } + async function resolveSecretValueForAgentAccess( + companyId: string, + secretId: string, + version: number | "latest", + context: AgentSecretReadContext, + ): Promise<{ value: string; version: number }> { + if (context.actorSource !== "agent_jwt") { + throw forbidden("Agent secret access requires a run-bound agent token"); + } + if (!isUuidLike(context.heartbeatRunId)) { + throw forbidden("Agent secret access requires a verified heartbeat run"); + } + if (!context.configPath.startsWith("env.") && !context.configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) { + throw forbidden("Secret access is not granted for this binding path"); + } + assertSecretBindingConfigPath({ targetType: "agent", configPath: context.configPath }); + + const run = await db + .select({ id: heartbeatRuns.id, contextSnapshot: heartbeatRuns.contextSnapshot }) + .from(heartbeatRuns) + .where(and( + eq(heartbeatRuns.id, context.heartbeatRunId), + eq(heartbeatRuns.companyId, companyId), + eq(heartbeatRuns.agentId, context.agentId), + eq(heartbeatRuns.status, "running"), + )) + .then((rows) => rows[0] ?? null); + if (!run) { + throw forbidden("Agent secret access requires a verified heartbeat run"); + } + + const decision = await authorization.decide({ + actor: { + type: "agent", + agentId: context.agentId, + companyId, + source: "agent_jwt", + keyId: context.keyId ?? null, + keyScope: context.keyScope ?? null, + runId: context.heartbeatRunId, + }, + action: "secrets:read", + resource: { type: "company", companyId }, + }); + if (!decision.allowed) { + throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); + } + + let bindingContext: SecretBindingContext = { + consumerType: "agent", + consumerId: context.agentId, + configPath: context.configPath, + responsibleUserId: context.responsibleUserId ?? null, + actorType: "agent", + actorId: context.agentId, + actorSource: context.actorSource, + issueId: context.issueId ?? null, + heartbeatRunId: context.heartbeatRunId, + }; + if (context.bindingId) { + const binding = await db + .select() + .from(companySecretBindings) + .where(and( + eq(companySecretBindings.id, context.bindingId), + eq(companySecretBindings.companyId, companyId), + eq(companySecretBindings.secretId, secretId), + eq(companySecretBindings.configPath, context.configPath), + )) + .then((rows) => rows[0] ?? null); + if (!binding) throw forbidden("Secret access is not granted for this agent"); + + const runContext = asRecord(run.contextSnapshot) ?? {}; + const manifest = (asRecord(runContext.paperclipSecrets) ?? {}).manifest; + const manifestBindingIds = new Set( + Array.isArray(manifest) + ? manifest.flatMap((entry) => { + const record = asRecord(entry) ?? {}; + return typeof record.bindingId === "string" ? [record.bindingId] : []; + }) + : [], + ); + const isDirectAgentBinding = binding.targetType === "agent" && binding.targetId === context.agentId; + if (!isDirectAgentBinding && !manifestBindingIds.has(binding.id)) { + throw forbidden("Secret access is not granted for this agent run"); + } + bindingContext = { + ...bindingContext, + consumerType: binding.targetType as SecretBindingTargetType, + consumerId: binding.targetId, + }; + } + + const runContext = asRecord(run.contextSnapshot) ?? {}; + const effectiveIssueId = context.issueId ?? ( + typeof runContext.issueId === "string" + ? runContext.issueId + : typeof (asRecord(runContext.paperclipIssue) ?? {}).id === "string" + ? String((asRecord(runContext.paperclipIssue) ?? {}).id) + : null + ); + bindingContext.issueId = effectiveIssueId; + + const accessContext: SecretConsumerContext = { + consumerType: "agent_api", + consumerId: context.agentId, + configPath: context.configPath, + responsibleUserId: context.responsibleUserId ?? null, + actorType: "agent", + actorId: context.agentId, + actorSource: context.actorSource, + issueId: effectiveIssueId, + heartbeatRunId: context.heartbeatRunId, + }; + + try { + const resolution = await resolveSecretValueInternal(companyId, secretId, version, { + bindingContext, + accessContext, + }); + await context.registerForRedaction(resolution.value); + await logActivity(db, { + companyId, + actorType: "agent", + actorId: context.agentId, + action: "secret.value.read", + entityType: "secret", + entityId: secretId, + agentId: context.agentId, + runId: context.heartbeatRunId, + issueId: effectiveIssueId, + details: { + configPath: context.configPath, + outcome: "success", + version: resolution.manifestEntry.version, + }, + }); + return { + value: resolution.value, + version: resolution.manifestEntry.version, + }; + } catch (error) { + const errorCode = secretResolutionErrorCode(error); + await logActivity(db, { + companyId, + actorType: "agent", + actorId: context.agentId, + action: "secret.value.read", + entityType: "secret", + entityId: secretId, + agentId: context.agentId, + runId: context.heartbeatRunId, + issueId: effectiveIssueId, + details: { + configPath: context.configPath, + outcome: "failure", + errorCode, + }, + }).catch(() => undefined); + if (errorCode === "binding_missing" || errorCode === "secret_scope_invalid") { + throw forbidden("Secret access is not granted for this agent"); + } + throw error; + } + } + + async function listAgentSecretAccess( + companyId: string, + context: Omit, + ): Promise { + if (context.actorSource !== "agent_jwt" || !isUuidLike(context.heartbeatRunId)) { + throw forbidden("Agent secret access requires a run-bound agent token"); + } + const run = await db + .select({ contextSnapshot: heartbeatRuns.contextSnapshot }) + .from(heartbeatRuns) + .where(and( + eq(heartbeatRuns.id, context.heartbeatRunId), + eq(heartbeatRuns.companyId, companyId), + eq(heartbeatRuns.agentId, context.agentId), + eq(heartbeatRuns.status, "running"), + )) + .then((rows) => rows[0] ?? null); + if (!run) throw forbidden("Agent secret access requires a verified heartbeat run"); + + const decision = await authorization.decide({ + actor: { + type: "agent", + agentId: context.agentId, + companyId, + source: "agent_jwt", + keyId: context.keyId ?? null, + keyScope: context.keyScope ?? null, + runId: context.heartbeatRunId, + }, + action: "secrets:read", + resource: { type: "company", companyId }, + }); + if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision)); + + const runContext = asRecord(run.contextSnapshot) ?? {}; + const manifest = (asRecord(runContext.paperclipSecrets) ?? {}).manifest; + const manifestBindingIds = Array.isArray(manifest) + ? manifest.flatMap((entry) => { + const bindingId = (asRecord(entry) ?? {}).bindingId; + return typeof bindingId === "string" ? [bindingId] : []; + }) + : []; + const [directBindings, runtimeBindings] = await Promise.all([ + db.select().from(companySecretBindings).where(and( + eq(companySecretBindings.companyId, companyId), + eq(companySecretBindings.targetType, "agent"), + eq(companySecretBindings.targetId, context.agentId), + or( + like(companySecretBindings.configPath, "env.%"), + like(companySecretBindings.configPath, `${AGENT_ACCESS_CONFIG_PATH_PREFIX}%`), + ), + )), + manifestBindingIds.length > 0 + ? db.select().from(companySecretBindings).where(and( + eq(companySecretBindings.companyId, companyId), + inArray(companySecretBindings.id, manifestBindingIds), + )) + : Promise.resolve([]), + ]); + const bindings = [...new Map([...directBindings, ...runtimeBindings].map((binding) => [binding.id, binding])).values()]; + if (bindings.length === 0) return []; + + const secrets = await db + .select() + .from(companySecrets) + .where(and( + eq(companySecrets.companyId, companyId), + eq(companySecrets.scope, "company"), + eq(companySecrets.status, "active"), + inArray(companySecrets.id, [...new Set(bindings.map((binding) => binding.secretId))]), + )); + const secretsById = new Map(secrets.map((secret) => [secret.id, secret])); + const bindingsBySecret = new Map(); + for (const binding of bindings) { + const current = bindingsBySecret.get(binding.secretId) ?? []; + current.push(binding); + bindingsBySecret.set(binding.secretId, current); + } + + return [...bindingsBySecret.entries()].flatMap(([secretId, secretBindings]) => { + const secret = secretsById.get(secretId); + if (!secret) return []; + const accessBinding = secretBindings.find((binding) => binding.configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)); + const selectedBinding = accessBinding ?? secretBindings[0]; + const hasEnv = secretBindings.some((binding) => binding.configPath.startsWith("env.")); + const hasApi = Boolean(accessBinding); + const versionSelector: SecretVersionSelector = selectedBinding.versionSelector === "latest" + ? "latest" + : Number(selectedBinding.versionSelector); + const delivery: AgentSecretAccessEntry["delivery"] = hasEnv && hasApi ? "both" : hasEnv ? "env" : "api"; + return [{ + secretId, + bindingId: selectedBinding.id, + configPath: selectedBinding.configPath, + key: secret.key, + name: secret.name, + description: secret.description ?? null, + delivery, + projectionClass: (selectedBinding.projectionClass ?? "unclassified") as SecretProjectionClass, + latestVersion: secret.latestVersion, + versionSelector, + resolvedVersion: versionSelector === "latest" ? secret.latestVersion : versionSelector, + }]; + }).sort((left, right) => left.key.localeCompare(right.key)); + } + async function resolveSecretVersion( companyId: string, secretId: string, @@ -3018,8 +3349,11 @@ export function secretService(db: Db) { getById, getByName, + getByKey, resolveSecretValue, resolveSecretVersion, + resolveSecretValueForAgentAccess, + listAgentSecretAccess, resolveSecretValueForEphemeralAccess, create: async ( @@ -3498,6 +3832,7 @@ export function secretService(db: Db) { projectionAllowlistKey?: string | null; }) => { await assertSecretInCompany(input.companyId, input.secretId); + assertSecretBindingConfigPath(input); assertClass3StaticLeaseAllowed({ targetType: input.targetType, configPath: input.configPath, @@ -3560,6 +3895,7 @@ export function secretService(db: Db) { }> = []; for (const ref of refs) { await assertSecretInCompany(companyId, ref.secretId); + assertSecretBindingConfigPath({ targetType: target.targetType, configPath: ref.configPath }); const projectionClass = ref.projectionClass ?? "unclassified"; const projectionAllowlistKey = ref.projectionAllowlistKey ?? null; assertClass3StaticLeaseAllowed({ diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 11b2107f90..56e633ccf1 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -352,6 +352,32 @@ When an issue needs browser/manual QA or a preview server, inspect its current e For commands, response fields, and MCP tools, read: `skills/paperclip/references/issue-workspaces.md` +## Reading Granted Secrets + +When authenticated with the current run's agent JWT, list the secrets available to that run before fetching a value: + +```bash +PAPERCLIP_API_BASE="${PAPERCLIP_API_URL%/}" +PAPERCLIP_API_BASE="${PAPERCLIP_API_BASE%/api}" +curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + "$PAPERCLIP_API_BASE/api/agents/me/secrets" +``` + +The list is metadata-only. Fetch a specific value only when needed; the request has no body: + +```bash +curl -s -X POST -H "Authorization: Bearer $PAPERCLIP_API_KEY" \ + "$PAPERCLIP_API_BASE/api/agents/me/secrets/github_token/value" +``` + +- An `env.*` secret binding also grants API read access; `access.*` bindings grant API access without env injection. +- Prefer env injection for values needed on every run by the adapter or its child processes. +- Prefer on-demand fetch for values used only on some runs, large or structured values, or skills/tools that do not inherit adapter env. +- Every value fetch, including failures, is audited in `secret_access_events` and `activity_log`; never print, persist, or paste fetched values into task comments. +- These endpoints require the current run-bound agent JWT. Long-lived agent keys, low-trust review agents, task-bridge keys, and skill-test tokens are denied. + +Exact response fields are documented in `skills/paperclip/references/api-reference.md`. + ## Critical Rules - **Never retry a 409.** The task belongs to someone else. diff --git a/skills/paperclip/references/api-reference.md b/skills/paperclip/references/api-reference.md index 11b1119528..9ee8821c10 100644 --- a/skills/paperclip/references/api-reference.md +++ b/skills/paperclip/references/api-reference.md @@ -1293,6 +1293,43 @@ Terminal states: `done`, `cancelled` | GET | `/api/companies/:companyId/secrets` | List secrets (metadata only) | | POST | `/api/companies/:companyId/secrets` | Create secret | | PATCH | `/api/secrets/:secretId` | Update secret value (creates new version) | +| GET | `/api/agents/me/secrets` | List secrets accessible to the current run (metadata only) | +| POST | `/api/agents/me/secrets/:key/value` | Fetch one granted secret value; request body is empty | + +Agent secret access requires the current run-bound agent JWT. An `env.*` binding implies API read access; an `access.*` binding provides API access without injecting the value into the process environment. + +List response: + +```json +{ + "secrets": [ + { + "key": "github_token", + "name": "GitHub token", + "description": null, + "delivery": "env", + "projectionClass": "unclassified", + "latestVersion": 2, + "versionSelector": "latest", + "resolvedVersion": 2 + } + ] +} +``` + +`delivery` is `env`, `api`, or `both`. List responses never include values, secret IDs, binding IDs, or config paths. Successful lists write `activity_log.action = secret.access.listed` but do not create `secret_access_events` rows. + +Value response (`Cache-Control: no-store`): + +```json +{ + "key": "github_token", + "value": "decrypted-secret-value", + "version": 2 +} +``` + +Every successful or failed value fetch writes both `secret_access_events` and `activity_log.action = secret.value.read`. Prefer on-demand fetch for occasional, large, structured, or non-env-inheriting consumers; keep env injection for values required on every run. Never log or paste fetched values into issues, comments, or documents. --- diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index d30af2b4a8..bbaa9cf67f 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -5,6 +5,7 @@ import type { AdapterEnvironmentTestResult, CompanySecret, EnvBinding, + EnvSecretRefBinding, Environment, } from "@paperclipai/shared"; import { AGENT_DEFAULT_MAX_CONCURRENT_RUNS, supportedEnvironmentDriversForAdapter } from "@paperclipai/shared"; @@ -51,6 +52,8 @@ import { EnvironmentVariablesEditor, type EnvironmentVariablesEditorHandle, } from "./environment-variables-editor"; +import { AgentSecretAccessEditor } from "./AgentSecretAccessEditor"; +import { AGENT_ACCESS_CONFIG_PATH_PREFIX } from "../lib/secret-delivery"; import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config"; import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata"; import { getAdapterDisplay, getAdapterLabel } from "../adapters/adapter-display-registry"; @@ -324,6 +327,29 @@ export function AgentConfigForm(props: AgentConfigFormProps) { return environmentVariablesEditorRef.current?.flushPendingDraft() ?? null; } + /** + * Replace the agent's API-access grants (top-level `access.` keys) with + * the complete set emitted by the Secret access editor. Added/changed aliases + * are marked into the overlay; aliases dropped from the set are marked + * `undefined` so `buildAgentUpdatePatch` strips them. + */ + const applyAccessGrants = useCallback((next: Record) => { + if (isCreate) return; + setOverlay((prev) => { + const effective = { ...(props.agent.adapterConfig ?? {}), ...prev.adapterConfig } as Record; + const nextAdapterConfig: Record = { ...prev.adapterConfig }; + for (const [alias, binding] of Object.entries(next)) { + nextAdapterConfig[`${AGENT_ACCESS_CONFIG_PATH_PREFIX}${alias}`] = binding; + } + for (const key of Object.keys(effective)) { + if (!key.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) continue; + const alias = key.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length); + if (!(alias in next)) nextAdapterConfig[key] = undefined; + } + return { ...prev, adapterConfig: nextAdapterConfig }; + }); + }, [isCreate, !isCreate ? props.agent : undefined]); // eslint-disable-line react-hooks/exhaustive-deps + /** Build accumulated patch and send to parent */ const handleCancel = useCallback(() => { setOverlay({ ...emptyOverlay }); @@ -1381,6 +1407,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) { /> + {!isCreate && ( + + + + )} + {/* Edit-only: timeout + grace period */} {!isCreate && ( <> diff --git a/ui/src/components/AgentSecretAccessEditor.test.tsx b/ui/src/components/AgentSecretAccessEditor.test.tsx new file mode 100644 index 0000000000..d8ec0f1fdc --- /dev/null +++ b/ui/src/components/AgentSecretAccessEditor.test.tsx @@ -0,0 +1,163 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CompanySecret, EnvSecretRefBinding } from "@paperclipai/shared"; + +// Stub SecretBindingPicker so the editor renders without CompanyContext / +// react-query. The stub exposes a button that binds a fixed secret. +vi.mock("./SecretBindingPicker", () => ({ + SecretBindingPicker: ({ + onChange, + }: { + onChange: (next: { secretId: string; version?: number | "latest" } | null) => void; + }) => ( + + ), +})); + +import { + AgentSecretAccessEditor, + parseAccessGrants, + parseEnvSecretRefs, + rowsToAccessMap, + summarizeAgentBindings, +} from "./AgentSecretAccessEditor"; + +function makeSecret(id: string, name: string): CompanySecret { + return { + id, + companyId: "co", + scope: "company", + ownerUserId: null, + userSecretDefinitionId: null, + key: id, + name, + provider: "local_encrypted", + status: "active", + managedMode: "paperclip_managed", + externalRef: null, + providerConfigId: null, + providerMetadata: null, + latestVersion: 1, + description: null, + lastResolvedAt: null, + lastRotatedAt: null, + deletedAt: null, + createdByAgentId: null, + createdByUserId: null, + createdAt: new Date(0), + updatedAt: new Date(0), + }; +} + +describe("AgentSecretAccessEditor model", () => { + const config = { + env: { + GH_TOKEN: { type: "secret_ref", secretId: "s1", version: 2 }, + PLAIN: { type: "plain", value: "hi" }, + }, + "access.STRIPE": { type: "secret_ref", secretId: "s1" }, + "access.BROKEN": { type: "plain", value: "nope" }, + model: "claude", + }; + + it("parses env secret refs, ignoring plain values", () => { + expect(parseEnvSecretRefs(config)).toEqual([{ name: "GH_TOKEN", secretId: "s1", version: 2 }]); + }); + + it("parses only well-formed top-level access.* secret refs", () => { + expect(parseAccessGrants(config)).toEqual([{ name: "STRIPE", secretId: "s1", version: "latest" }]); + }); + + it("summarizes bindings per secret with both delivery modes", () => { + const summary = summarizeAgentBindings(parseEnvSecretRefs(config), parseAccessGrants(config)); + expect(summary).toEqual([{ secretId: "s1", envKeys: ["GH_TOKEN"], apiAliases: ["STRIPE"] }]); + }); + + it("drops incomplete, invalid-alias, and unselected rows from the emitted access map", () => { + expect( + rowsToAccessMap([ + { id: "1", alias: "OK", secretId: "s1", version: "latest" }, + { id: "2", alias: "", secretId: "s1", version: "latest" }, // no alias + { id: "3", alias: "1BAD", secretId: "s1", version: "latest" }, // invalid alias + { id: "4", alias: "NOSECRET", secretId: "", version: "latest" }, // no secret + ]), + ).toEqual({ OK: { type: "secret_ref", secretId: "s1", version: "latest" } }); + }); +}); + +describe("AgentSecretAccessEditor component", () => { + let container: HTMLDivElement; + let root: Root | null = null; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + flushSync(() => root?.unmount()); + root = null; + container.remove(); + vi.clearAllMocks(); + }); + + function render(node: React.ReactNode) { + root = createRoot(container); + flushSync(() => root!.render(node)); + } + + const secrets = [makeSecret("s1", "STRIPE_KEY")]; + + it("shows the delivery-mode overview for existing bindings", () => { + render( + {}} + />, + ); + expect(container.textContent).toContain("STRIPE_KEY"); + expect(container.textContent).toContain("Env var"); + expect(container.textContent).toContain("API access"); + expect(container.textContent).toContain("env.GH_TOKEN"); + expect(container.textContent).toContain("access.STRIPE"); + }); + + it("adds an API-access grant, emitting an access. secret_ref", () => { + const emitted: Array> = []; + render( + emitted.push(next)} + />, + ); + + // "Add API access" appends an editable row. + const addButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Add API access"), + )!; + flushSync(() => addButton.click()); + + // Type an alias. + const aliasInput = container.querySelector('input[aria-label="Access alias"]')!; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + setter.call(aliasInput, "STRIPE"); + flushSync(() => aliasInput.dispatchEvent(new Event("input", { bubbles: true }))); + + // Bind a secret via the stubbed picker. + const pick = container.querySelector('[data-testid="pick-secret"]')!; + flushSync(() => pick.click()); + + const last = emitted.at(-1)!; + expect(last).toEqual({ STRIPE: { type: "secret_ref", secretId: "s1", version: "latest" } }); + }); +}); diff --git a/ui/src/components/AgentSecretAccessEditor.tsx b/ui/src/components/AgentSecretAccessEditor.tsx new file mode 100644 index 0000000000..88d95f1b48 --- /dev/null +++ b/ui/src/components/AgentSecretAccessEditor.tsx @@ -0,0 +1,348 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { KeyRound, Plus, ServerCog, Trash2, Variable } from "lucide-react"; +import type { CompanySecret, EnvSecretRefBinding, SecretVersionSelector } from "@paperclipai/shared"; +import { cn } from "../lib/utils"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { SecretBindingPicker, type SecretBindingValue } from "./SecretBindingPicker"; +import { + AGENT_ACCESS_CONFIG_PATH_PREFIX, + ENV_CONFIG_PATH_PREFIX, + SECRET_ALIAS_RE, + deliveryModeDescription, +} from "../lib/secret-delivery"; +import { envKeyFromSecretName } from "./environment-variables-editor/model"; + +/* -------------------------------------------------------------------------- */ +/* Pure model (exported for tests) */ +/* -------------------------------------------------------------------------- */ + +export interface AgentSecretRefEntry { + /** env KEY (env delivery) or access ALIAS (API-access delivery). */ + name: string; + secretId: string; + version: SecretVersionSelector; +} + +function asRecord(value: unknown): Record | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function readSecretRef(raw: unknown): { secretId: string; version: SecretVersionSelector } | null { + const binding = asRecord(raw); + if (!binding || binding.type !== "secret_ref") return null; + const secretId = typeof binding.secretId === "string" ? binding.secretId : ""; + if (!secretId) return null; + const version: SecretVersionSelector = typeof binding.version === "number" ? binding.version : "latest"; + return { secretId, version }; +} + +/** Secret-ref bindings delivered as environment variables (`config.env.`). */ +export function parseEnvSecretRefs(config: Record | null | undefined): AgentSecretRefEntry[] { + const env = asRecord(config?.env); + if (!env) return []; + const entries: AgentSecretRefEntry[] = []; + for (const [key, raw] of Object.entries(env)) { + const ref = readSecretRef(raw); + if (ref) entries.push({ name: key, ...ref }); + } + return entries; +} + +/** Secret-ref bindings delivered via the agent API (top-level `access.`). */ +export function parseAccessGrants(config: Record | null | undefined): AgentSecretRefEntry[] { + if (!config) return []; + const entries: AgentSecretRefEntry[] = []; + for (const [key, raw] of Object.entries(config)) { + if (!key.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) continue; + const ref = readSecretRef(raw); + if (ref) entries.push({ name: key.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length), ...ref }); + } + return entries; +} + +export interface AgentSecretBindingSummary { + secretId: string; + envKeys: string[]; + apiAliases: string[]; +} + +/** Group env + API bindings by secret so the overview can show delivery mode per secret. */ +export function summarizeAgentBindings( + envBindings: readonly AgentSecretRefEntry[], + apiBindings: readonly AgentSecretRefEntry[], +): AgentSecretBindingSummary[] { + const bySecret = new Map(); + const ensure = (secretId: string) => { + let summary = bySecret.get(secretId); + if (!summary) { + summary = { secretId, envKeys: [], apiAliases: [] }; + bySecret.set(secretId, summary); + } + return summary; + }; + for (const entry of envBindings) ensure(entry.secretId).envKeys.push(entry.name); + for (const entry of apiBindings) ensure(entry.secretId).apiAliases.push(entry.name); + return [...bySecret.values()]; +} + +let accessRowCounter = 0; +function nextAccessRowId(): string { + accessRowCounter += 1; + return `access-row-${accessRowCounter}`; +} + +interface AccessRow { + id: string; + alias: string; + secretId: string; + version: SecretVersionSelector; +} + +function entriesToRows(entries: readonly AgentSecretRefEntry[]): AccessRow[] { + return entries.map((entry) => ({ + id: nextAccessRowId(), + alias: entry.name, + secretId: entry.secretId, + version: entry.version, + })); +} + +/** Complete, valid API-access grants keyed by alias. Incomplete/invalid/duplicate rows are dropped. */ +export function rowsToAccessMap(rows: readonly AccessRow[]): Record { + const map: Record = {}; + for (const row of rows) { + const alias = row.alias.trim(); + if (!alias || !SECRET_ALIAS_RE.test(alias) || !row.secretId) continue; + map[alias] = { type: "secret_ref", secretId: row.secretId, version: row.version }; + } + return map; +} + +/** Stable key for change-detection between the controlled value and the local draft. */ +export function normalizeAccessMapKey(map: Record): string { + return JSON.stringify( + Object.keys(map) + .sort() + .map((alias) => { + const binding = map[alias]!; + return [alias, binding.secretId, binding.version ?? "latest"]; + }), + ); +} + +/* -------------------------------------------------------------------------- */ +/* Component */ +/* -------------------------------------------------------------------------- */ + +export interface AgentSecretAccessEditorProps { + /** Effective adapter config (env + top-level `access.*`), reflecting unsaved edits. */ + config: Record; + secrets: readonly CompanySecret[]; + /** + * Emit the complete desired set of API-access grants (alias → secret_ref). The + * parent diffs this against the current `access.*` keys to add/remove them. + */ + onChange: (next: Record) => void; + disabled?: boolean; +} + +function DeliveryBadge({ mode }: { mode: "env" | "api" }) { + if (mode === "env") { + return ( + + Env var + + ); + } + return ( + + API access + + ); +} + +export function AgentSecretAccessEditor({ config, secrets, onChange, disabled }: AgentSecretAccessEditorProps) { + const envBindings = useMemo(() => parseEnvSecretRefs(config), [config]); + const apiBindings = useMemo(() => parseAccessGrants(config), [config]); + const summaries = useMemo(() => summarizeAgentBindings(envBindings, apiBindings), [envBindings, apiBindings]); + + const incomingMap = useMemo(() => rowsToAccessMap(entriesToRows(apiBindings)), [apiBindings]); + const incomingKey = useMemo(() => normalizeAccessMapKey(incomingMap), [incomingMap]); + + const [rows, setRows] = useState(() => entriesToRows(apiBindings)); + const lastEmittedKeyRef = useRef(incomingKey); + const lastIncomingKeyRef = useRef(incomingKey); + + // Controlled sync (mirrors the env editor): adopt genuine external changes + // (Cancel / agent refetch) but never clobber a local draft that produced the + // incoming value (the echo of our own emit) or an in-progress incomplete row. + useEffect(() => { + if (incomingKey === lastIncomingKeyRef.current) return; + lastIncomingKeyRef.current = incomingKey; + if (incomingKey === lastEmittedKeyRef.current) return; + setRows(entriesToRows(apiBindings)); + }, [incomingKey, apiBindings]); + + const secretName = (secretId: string): string => + secrets.find((secret) => secret.id === secretId)?.name ?? `${secretId.slice(0, 8)}…`; + + function emit(nextRows: AccessRow[]) { + setRows(nextRows); + const map = rowsToAccessMap(nextRows); + lastEmittedKeyRef.current = normalizeAccessMapKey(map); + onChange(map); + } + + function patchRow(id: string, patch: Partial) { + emit(rows.map((row) => (row.id === id ? { ...row, ...patch } : row))); + } + + function removeRow(id: string) { + emit(rows.filter((row) => row.id !== id)); + } + + function addRow() { + setRows((prev) => [...prev, { id: nextAccessRowId(), alias: "", secretId: "", version: "latest" }]); + } + + const aliasCounts = useMemo(() => { + const counts = new Map(); + for (const row of rows) { + const alias = row.alias.trim(); + if (alias) counts.set(alias, (counts.get(alias) ?? 0) + 1); + } + return counts; + }, [rows]); + + const hasBindings = summaries.length > 0; + + return ( +
+ {/* Overview: every secret bound to this agent + how it is delivered. */} + {hasBindings ? ( +
+ {summaries.map((summary) => ( +
+ + {secretName(summary.secretId)} + {summary.envKeys.length > 0 ? : null} + {summary.apiAliases.length > 0 ? : null} + + {[ + ...summary.envKeys.map((key) => `${ENV_CONFIG_PATH_PREFIX}${key}`), + ...summary.apiAliases.map((alias) => `${AGENT_ACCESS_CONFIG_PATH_PREFIX}${alias}`), + ].join(" · ")} + +
+ ))} +
+ ) : ( +

No secrets are bound to this agent yet.

+ )} + + {/* Editable API-access grants (access.). */} +
+
+ API access (no env var) +
+ {rows.length > 0 ? ( +
+ {rows.map((row) => { + const trimmedAlias = row.alias.trim(); + const aliasInvalid = Boolean(trimmedAlias) && !SECRET_ALIAS_RE.test(trimmedAlias); + const aliasDuplicate = Boolean(trimmedAlias) && (aliasCounts.get(trimmedAlias) ?? 0) > 1; + const bindingValue: SecretBindingValue | null = row.secretId + ? { secretId: row.secretId, version: row.version } + : null; + return ( +
+
+
+ patchRow(row.id, { alias: event.target.value })} + onBlur={(event) => { + const next = event.target.value.trim(); + if (next && !SECRET_ALIAS_RE.test(next)) { + const suggested = envKeyFromSecretName(next); + if (suggested && suggested !== next) patchRow(row.id, { alias: suggested }); + } + }} + placeholder="ALIAS" + aria-label="Access alias" + disabled={disabled} + className={cn( + "h-9 font-mono text-sm", + (aliasInvalid || aliasDuplicate) && "border-destructive text-destructive", + )} + /> +
+
+ + patchRow(row.id, { + secretId: next?.secretId ?? "", + version: next?.version ?? "latest", + alias: + !row.alias.trim() && next?.secretId + ? envKeyFromSecretName(secretName(next.secretId)) + : row.alias, + }) + } + label="" + placeholder="Select secret" + disabled={disabled} + /> +
+ +
+ {aliasInvalid ? ( +

+ Invalid alias — use letters, digits and _ +

+ ) : aliasDuplicate ? ( +

Duplicate alias

+ ) : null} +
+ ); + })} +
+ ) : null} + + +
+ +

+ {deliveryModeDescription("api")} The agent reads them by alias through GET /agents/me/secrets. +

+
+ ); +} diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index eea519c93d..e209036db1 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -46,6 +46,8 @@ export const help: Record = { args: "Command-line arguments, comma-separated.", extraArgs: "Extra CLI arguments for local adapters, comma-separated.", envVars: "Environment variables injected into the adapter process. Use plain values or secret references.", + secretAccess: + "Secrets this agent can reach. Env-var bindings are injected at run start; API-access bindings are fetched on demand via the run-bound agent API and never written to the environment.", bootstrapPrompt: "Only sent when Paperclip starts a fresh session. Use this for stable setup guidance that should not be repeated on every heartbeat.", payloadTemplateJson: "Optional JSON merged into remote adapter request payloads before Paperclip adds its standard wake and workspace fields.", webhookUrl: "The URL that receives POST requests when the agent is invoked.", diff --git a/ui/src/index.css b/ui/src/index.css index fced100ebc..d346948b9a 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1835,6 +1835,7 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] { --gtc-62: 1fr minmax(0,18rem); --gtc-63: 280px 1fr; --gtc-64: minmax(0,20rem) 1fr; + --gtc-65: minmax(8rem,0.38fr) minmax(0,0.62fr) auto; --gtr-1: auto auto; /* Extracted from ui/src/components/ui/card.tsx (grid-rows-[auto_auto]). */ --gtr-2: 1fr; /* Extracted from ui/src/pages/CompanySkills.tsx (grid-rows-[1fr]). */ --gtr-3: 0fr; /* Extracted from ui/src/pages/CompanySkills.tsx (grid-rows-[0fr]). */ diff --git a/ui/src/lib/secret-delivery.test.ts b/ui/src/lib/secret-delivery.test.ts new file mode 100644 index 0000000000..e14817ccad --- /dev/null +++ b/ui/src/lib/secret-delivery.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + aliasFromConfigPath, + consumerTypeLabel, + deliveryModeForConfigPath, + deliveryModeLabel, +} from "./secret-delivery"; + +describe("deliveryModeForConfigPath", () => { + it("classifies env config paths", () => { + expect(deliveryModeForConfigPath("env.OPENAI_API_KEY")).toBe("env"); + }); + + it("classifies API-access config paths", () => { + expect(deliveryModeForConfigPath("access.STRIPE")).toBe("api"); + }); + + it("falls back to config for other paths and empties", () => { + expect(deliveryModeForConfigPath("headers.authorization")).toBe("config"); + expect(deliveryModeForConfigPath(null)).toBe("config"); + expect(deliveryModeForConfigPath("")).toBe("config"); + }); +}); + +describe("deliveryModeLabel", () => { + it("maps each mode to a human label", () => { + expect(deliveryModeLabel("env")).toBe("Env var"); + expect(deliveryModeLabel("api")).toBe("API access"); + expect(deliveryModeLabel("config")).toBe("Config"); + }); +}); + +describe("aliasFromConfigPath", () => { + it("strips the delivery prefix", () => { + expect(aliasFromConfigPath("env.GH_TOKEN")).toBe("GH_TOKEN"); + expect(aliasFromConfigPath("access.STRIPE")).toBe("STRIPE"); + }); + + it("returns the raw path when no known prefix applies", () => { + expect(aliasFromConfigPath("headers.authorization")).toBe("headers.authorization"); + expect(aliasFromConfigPath(null)).toBe(""); + }); +}); + +describe("consumerTypeLabel", () => { + it("renders agent_api and other multiword consumers sensibly", () => { + expect(consumerTypeLabel("agent_api")).toBe("Agent API"); + expect(consumerTypeLabel("plugin_worker")).toBe("Plugin worker"); + expect(consumerTypeLabel("tool_connection")).toBe("Tool connection"); + }); + + it("capitalizes single-word consumer types", () => { + expect(consumerTypeLabel("agent")).toBe("Agent"); + expect(consumerTypeLabel("project")).toBe("Project"); + }); +}); diff --git a/ui/src/lib/secret-delivery.ts b/ui/src/lib/secret-delivery.ts new file mode 100644 index 0000000000..78cd0805ed --- /dev/null +++ b/ui/src/lib/secret-delivery.ts @@ -0,0 +1,83 @@ +import type { SecretAccessEvent } from "@paperclipai/shared"; + +/** + * Delivery mode for an agent secret binding, derived from its `configPath`. + * + * The server (see `AGENT_ACCESS_CONFIG_PATH_PREFIX` in + * `server/src/services/secrets.ts`) treats a binding's config path as the + * source of truth for how a secret reaches the runtime: + * - `env.` — injected as an environment variable at run start. + * - `access.` — fetched on demand via the run-bound agent API + * (`GET /agents/me/secrets`), never written to the env. + * - anything else — a generic adapter config path (rendered as "Config"). + */ +export type SecretDeliveryMode = "env" | "api" | "config"; + +/** Prefix for env-var delivery config paths. Mirrors the server convention. */ +export const ENV_CONFIG_PATH_PREFIX = "env."; +/** Prefix for API-access (no env var) delivery config paths. Mirrors the server's `AGENT_ACCESS_CONFIG_PATH_PREFIX`. */ +export const AGENT_ACCESS_CONFIG_PATH_PREFIX = "access."; + +/** Valid env-var name / access alias (matches the server's `ENV_KEY_RE`). */ +export const SECRET_ALIAS_RE = /^[A-Za-z_][A-Za-z0-9_]*$/; + +export function deliveryModeForConfigPath(configPath: string | null | undefined): SecretDeliveryMode { + if (!configPath) return "config"; + if (configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) return "api"; + if (configPath.startsWith(ENV_CONFIG_PATH_PREFIX)) return "env"; + return "config"; +} + +/** Short human label for a delivery mode. */ +export function deliveryModeLabel(mode: SecretDeliveryMode): string { + switch (mode) { + case "env": + return "Env var"; + case "api": + return "API access"; + default: + return "Config"; + } +} + +/** One-line explanation of a delivery mode, for tooltips/hints. */ +export function deliveryModeDescription(mode: SecretDeliveryMode): string { + switch (mode) { + case "env": + return "Injected as an environment variable at run start."; + case "api": + return "Fetched on demand via the run-bound agent API. Never written to the environment."; + default: + return "Provided through adapter configuration."; + } +} + +/** The env KEY / access ALIAS carried by a config path (the part after the prefix). */ +export function aliasFromConfigPath(configPath: string | null | undefined): string { + if (!configPath) return ""; + if (configPath.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) { + return configPath.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length); + } + if (configPath.startsWith(ENV_CONFIG_PATH_PREFIX)) { + return configPath.slice(ENV_CONFIG_PATH_PREFIX.length); + } + return configPath; +} + +/** + * Human label for a secret access-event `consumerType`. Runtime consumers are + * emitted as raw enum values (e.g. `agent_api`, `plugin_worker`) which read + * poorly when merely capitalized; map the ones that need help explicitly. + */ +export function consumerTypeLabel(consumerType: SecretAccessEvent["consumerType"]): string { + switch (consumerType) { + case "agent_api": + return "Agent API"; + case "plugin_worker": + return "Plugin worker"; + case "tool_connection": + return "Tool connection"; + default: + return consumerType.charAt(0).toUpperCase() + consumerType.slice(1); + } +} diff --git a/ui/src/pages/Secrets.tsx b/ui/src/pages/Secrets.tsx index 0acc27e2b5..112301986d 100644 --- a/ui/src/pages/Secrets.tsx +++ b/ui/src/pages/Secrets.tsx @@ -65,6 +65,13 @@ import { ApiError } from "../api/client"; import { accessApi, type CompanyUserDirectoryEntry } from "../api/access"; import { agentsApi } from "../api/agents"; import { envKeyFromSecretName } from "../components/environment-variables-editor/model"; +import { + AGENT_ACCESS_CONFIG_PATH_PREFIX, + aliasFromConfigPath, + consumerTypeLabel, + deliveryModeForConfigPath, + deliveryModeLabel, +} from "../lib/secret-delivery"; import { queryKeys } from "../lib/queryKeys"; import { EmptyState } from "../components/EmptyState"; import { Button } from "@/components/ui/button"; @@ -4224,6 +4231,25 @@ function envKeysReferencingSecret(env: unknown, reference: AgentAccessReference) .sort(); } +/** + * Top-level `access.` keys in an agent's adapter config that resolve to + * this secret (API-access delivery). Only company secrets support API access; + * user secrets remain env-only. + */ +function apiAliasesReferencingSecret(adapterConfig: unknown, reference: AgentAccessReference): string[] { + if (reference.kind !== "company") return []; + if (typeof adapterConfig !== "object" || adapterConfig === null || Array.isArray(adapterConfig)) return []; + return Object.entries(adapterConfig as Record) + .filter(([key, binding]) => { + if (!key.startsWith(AGENT_ACCESS_CONFIG_PATH_PREFIX)) return false; + if (typeof binding !== "object" || binding === null) return false; + const record = binding as Record; + return record.type === "secret_ref" && record.secretId === reference.secret.id; + }) + .map(([key]) => key.slice(AGENT_ACCESS_CONFIG_PATH_PREFIX.length)) + .sort(); +} + function AgentAccessSection({ companyId, reference, @@ -4253,14 +4279,15 @@ function AgentAccessSection({ const agentAccess = useMemo( () => agents - .map((agent) => ({ - agent, - envKeys: envKeysReferencingSecret( - (agent.adapterConfig as Record | null)?.env, - reference, - ), - })) - .filter((entry) => entry.envKeys.length > 0), + .map((agent) => { + const adapterConfig = (agent.adapterConfig as Record | null) ?? null; + return { + agent, + envKeys: envKeysReferencingSecret(adapterConfig?.env, reference), + apiAliases: apiAliasesReferencingSecret(adapterConfig, reference), + }; + }) + .filter((entry) => entry.envKeys.length > 0 || entry.apiAliases.length > 0), [agents, reference], ); const grantableAgents = useMemo( @@ -4325,8 +4352,10 @@ function AgentAccessSection({ const adapterConfig = { ...((detail.adapterConfig ?? {}) as Record) }; const env = { ...((adapterConfig.env ?? {}) as Record) }; const keys = envKeysReferencingSecret(env, reference); - if (keys.length === 0) return detail; + const aliases = apiAliasesReferencingSecret(adapterConfig, reference); + if (keys.length === 0 && aliases.length === 0) return detail; for (const key of keys) delete env[key]; + for (const alias of aliases) delete adapterConfig[`${AGENT_ACCESS_CONFIG_PATH_PREFIX}${alias}`]; return agentsApi.update( agentId, { adapterConfig: { ...adapterConfig, env }, replaceAdapterConfig: true }, @@ -4352,7 +4381,7 @@ function AgentAccessSection({

{reference.kind === "company" - ? "These agents receive this secret as an environment variable at run start." + ? "Add here to inject this secret as an environment variable at run start. API-access grants (fetched on demand, no env var) are managed from each agent's Secret access settings and shown below." : "These agents resolve the responsible user's value as an environment variable at run start."}

{agentsQuery.isPending ? ( @@ -4365,15 +4394,30 @@ function AgentAccessSection({ <> {agentAccess.length > 0 ? (
    - {agentAccess.map(({ agent, envKeys }) => ( + {agentAccess.map(({ agent, envKeys, apiAliases }) => (
  • {agent.name} - - {envKeys.join(", ")} - + + {envKeys.length > 0 ? ( + + Env · {envKeys.join(", ")} + + ) : null} + {apiAliases.length > 0 ? ( + + API · {apiAliases.join(", ")} + + ) : null} +